Compare commits
81 Commits
70acfe6d5e
...
test/fix-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c3d78245f | ||
|
|
f5045c3cf0 | ||
|
|
696c50f0fc | ||
|
|
b8bdbf0a8d | ||
|
|
1ea9596006 | ||
|
|
cdbe71eaec | ||
|
|
125a00819d | ||
|
|
b4c31374c0 | ||
|
|
c8b7f5ec2e | ||
|
|
0528334eb4 | ||
|
|
e033d716dd | ||
|
|
e195d5d189 | ||
|
|
533e89c405 | ||
|
|
083244bc83 | ||
|
|
9def0fdc32 | ||
|
|
512756cb48 | ||
|
|
19d2f21fc3 | ||
|
|
5074e8f8f8 | ||
|
|
18a08b0e7a | ||
|
|
026e4ba3e4 | ||
|
|
58fd7fa5c6 | ||
| 755442fb70 | |||
| d31515bc79 | |||
|
|
1710c9349d | ||
|
|
cd025b1f92 | ||
|
|
2c681cf65c | ||
|
|
92099685e6 | ||
|
|
7e0c92368e | ||
|
|
817f5e02ca | ||
|
|
d3be0a269f | ||
|
|
8e318b0b52 | ||
| b729e90e38 | |||
|
|
acfcf773f7 | ||
|
|
1ea7f7a63f | ||
|
|
7289adb7a0 | ||
|
|
19d7aeefb0 | ||
| 92c0610dcb | |||
|
|
5fce08ab75 | ||
|
|
1c9dff0932 | ||
|
|
fd83b18642 | ||
| 6db05c1dc6 | |||
|
|
84536956c4 | ||
|
|
459da00820 | ||
|
|
f883d45452 | ||
|
|
11ff823fe2 | ||
|
|
cfd9365d07 | ||
|
|
f2f76dec56 | ||
|
|
2d42ef3ecd | ||
|
|
e19158558d | ||
|
|
bf4db389ff | ||
|
|
964a68cdfd | ||
|
|
21b39d4e5c | ||
| 68b6ab593a | |||
|
|
3658589d94 | ||
| 4e42ddfee4 | |||
|
|
a80ea7b892 | ||
| 6a6b8e0a1b | |||
|
|
f7c6caebc1 | ||
| ddb170cffc | |||
|
|
79e2ae9b87 | ||
| 66f2718714 | |||
|
|
4891e965bb | ||
| f2f5ddb8ce | |||
|
|
2fab6be58a | ||
|
|
549f4a1510 | ||
| 4d13ea5de6 | |||
|
|
02ee4af49d | ||
|
|
cc253c24db | ||
| 45d343b1c1 | |||
|
|
2eeed60387 | ||
|
|
63a1034e3d | ||
| 7b9bdb9a36 | |||
|
|
1561e1b861 | ||
|
|
3f7149d622 | ||
|
|
bc9cc265e1 | ||
| a48c65bfb8 | |||
|
|
fdf72bdb96 | ||
|
|
dbcc55d7fc | ||
|
|
f8a0370910 | ||
| 9a59fa5525 | |||
|
|
41374da6ea |
@@ -1,14 +1,12 @@
|
||||
name: Code Analysis and Production Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,6 +30,15 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Check package.json version vs production
|
||||
if: "github.ref == 'refs/heads/main'"
|
||||
env:
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
PROD_PORT: ${{ vars.PROD_PORT }}
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
run: bash scripts/check-version-against-prod.sh
|
||||
|
||||
- name: gitleaks (Secrets Scanning)
|
||||
run: |
|
||||
# Try to get the latest release asset URL
|
||||
@@ -91,8 +98,9 @@ jobs:
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
|
||||
deploy-production:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
@@ -121,8 +129,9 @@ jobs:
|
||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
|
||||
deploy-test:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
name: Require Package Version Change
|
||||
name: Require Package Version Change (disabled)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check package.json version changed
|
||||
run: scripts/check-package-version-changed.sh origin/main
|
||||
- name: Disabled
|
||||
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"
|
||||
|
||||
28
.github/workflows/android-ci.yml
vendored
Normal file
28
.github/workflows/android-ci.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper/
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
- name: Build
|
||||
run: |
|
||||
./gradlew :app:assembleDebug
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -88,6 +88,12 @@ out
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Android / Gradle generated and machine-local files
|
||||
/android-app/.gradle/
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
!.output/.gitkeep
|
||||
|
||||
36
ANDROID_ARCHITECTURE.md
Normal file
36
ANDROID_ARCHITECTURE.md
Normal file
@@ -0,0 +1,36 @@
|
||||
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
|
||||
|
||||
Packages/Module Struktur:
|
||||
- app/ (Android-App module)
|
||||
- src/main/java/de/harheimertc/
|
||||
- ui/
|
||||
- navigation/ (NavGraph, Routes)
|
||||
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
|
||||
- components/ (TopBar, BottomNav, Cards, Modals)
|
||||
- theme/ (Color.kt, Typography.kt, Theme.kt)
|
||||
- data/
|
||||
- api/ (Retrofit interfaces, DTOs)
|
||||
- repository/ (Repositories für Domain-Modelle)
|
||||
- local/ (Room DAOs, Entities)
|
||||
- di/ (Hilt Modules)
|
||||
- domain/ (UseCases, Business-Logic)
|
||||
- util/ (Extensions, DateUtils, ImageUtils)
|
||||
- auth/ (AuthManager, Passkeys helper)
|
||||
|
||||
Wichtige Dateien:
|
||||
- `MainActivity.kt` — Hosts Compose NavHost
|
||||
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
|
||||
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
|
||||
- `Repository` Layer — entkoppelt UI von Netz
|
||||
- `Room` Entities — für Caching von Termine/News/Galerie
|
||||
|
||||
Auth-Strategie:
|
||||
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
|
||||
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
|
||||
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
|
||||
|
||||
Build / Module Tipps:
|
||||
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
|
||||
- Verwende Gradle Kotlin DSL (build.gradle.kts).
|
||||
|
||||
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)
|
||||
372
ANDROID_KOTLIN_PLAN.md
Normal file
372
ANDROID_KOTLIN_PLAN.md
Normal file
@@ -0,0 +1,372 @@
|
||||
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
|
||||
|
||||
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
|
||||
|
||||
1) Zusammenfassung der Entscheidungen
|
||||
- Plattform: Native Android
|
||||
- Sprache: Kotlin
|
||||
- UI-Toolkit: Jetpack Compose (Compose Material3)
|
||||
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
|
||||
- DI: Hilt
|
||||
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
|
||||
- Bild-Loading: Coil
|
||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||
- Background/Sync: WorkManager
|
||||
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||
|
||||
2) Design & Farben
|
||||
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
|
||||
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
|
||||
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
|
||||
|
||||
3) Empfohlene Abhängigkeiten (erste Implementierung)
|
||||
- androidx.compose.* (ui, material3, navigation)
|
||||
- androidx.lifecycle:lifecycle-viewmodel-ktx
|
||||
- com.google.dagger:hilt-android
|
||||
- retrofit2 + converter-moshi / kotlinx-serialization
|
||||
- io.coil-kt:coil-compose
|
||||
- androidx.room:room-runtime + room-ktx
|
||||
- androidx.work:work-runtime-ktx
|
||||
- androidx.datastore:datastore-preferences
|
||||
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
|
||||
- io.sentry:sentry-android (optional)
|
||||
|
||||
4) Detaillierte Abhakliste (Schritte)
|
||||
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
|
||||
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
|
||||
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
|
||||
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
|
||||
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
||||
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
||||
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
||||
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
||||
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
||||
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
||||
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
|
||||
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
|
||||
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
|
||||
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
|
||||
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
|
||||
- [x] `/` Startseite
|
||||
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
|
||||
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
|
||||
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
|
||||
- [x] `/kontakt`: Formular-Screen vorhanden
|
||||
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
|
||||
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
|
||||
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
|
||||
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
|
||||
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
|
||||
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
|
||||
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
|
||||
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
|
||||
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
|
||||
- [x] `/training/trainer`
|
||||
- [x] `/training/anfaenger`
|
||||
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
||||
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
||||
- [x] `/impressum`
|
||||
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
||||
[x] 10b. Newsletter-Screens portieren
|
||||
- [x] `/newsletter/subscribe`
|
||||
- [x] `/newsletter/unsubscribe`
|
||||
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
||||
[x] 10c. Auth-Screens portieren
|
||||
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
||||
- [x] `/registrieren`
|
||||
- [x] `/passwort-vergessen`
|
||||
[x] 10d. Mitgliederbereich portieren
|
||||
- [x] `/mitgliederbereich`: Übersicht
|
||||
- [x] `/mitgliederbereich/mitglieder`
|
||||
- [x] `/mitgliederbereich/news`
|
||||
- [x] `/mitgliederbereich/profil`
|
||||
- [x] `/mitgliederbereich/api`
|
||||
[x] 10e. CMS-Screens nach Rollenberechtigung portieren
|
||||
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||
[x] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
||||
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
|
||||
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||
- [x] Registrierung und Passwort-Reset
|
||||
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
|
||||
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
|
||||
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||
[x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
|
||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
|
||||
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
|
||||
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
|
||||
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
|
||||
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
|
||||
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
|
||||
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
|
||||
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
|
||||
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (Test‑Bindings bereitgestellt)
|
||||
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||
- [x] Technische Release-Basis vorbereitet: `ANDROID_VERSION_CODE` und `ANDROID_VERSION_NAME` als Gradle-Properties eingeführt (`android-app/gradle.properties`) und im App-Gradle verdrahtet.
|
||||
- [x] Production-Release-Flavor auf Produktiv-Backend parametrisierbar gemacht (`PRODUCTION_API_BASE_URL`, Default `https://harheimertc.de/`).
|
||||
- [x] Release-Signing per sicheren Gradle-Properties vorbereitet (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) statt Hardcoding.
|
||||
- [x] `:app:assembleProductionRelease` erfolgreich gebaut (Stand 2026-05-29).
|
||||
- [x] Play-Store-Listing-Basis ergänzt: Datenschutzseite unter `/datenschutz` sowie Skripte für Icon/Feature-Graphic-Export und Screenshot-Anonymisierung inklusive Anleitung (`android-app/PLAYSTORE_ASSETS.md`).
|
||||
- [x] Konto-Lösch-URL für Play Store ergänzt: öffentliche Seite unter `/konto-loeschen` inklusive Prozessbeschreibung.
|
||||
- [ ] Offen: Finales Upload-Keystore + Credentials in CI/Build-Host hinterlegen, Play-Store-Release-Notes und Store-Metadaten pflegen.
|
||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||
|
||||
5) Kurzzeit-MVP (Priorität für erste Version)
|
||||
- [x] A. Auth (Login/Logout)
|
||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||
- [x] C. Kontaktformular (absenden)
|
||||
- [x] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||
- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
|
||||
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
|
||||
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
|
||||
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
|
||||
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
|
||||
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
|
||||
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
|
||||
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
|
||||
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
|
||||
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
|
||||
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
|
||||
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
|
||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
|
||||
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
|
||||
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
|
||||
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
|
||||
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
|
||||
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
|
||||
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
|
||||
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
|
||||
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
|
||||
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
|
||||
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
|
||||
- 2026-05-29: Play-Store-Listing-Vorbereitung ergänzt: eigenständige Web-Datenschutzseite (`/datenschutz`) sowie Asset-/Anonymisierungs-Skripte und Anleitung in `android-app/PLAYSTORE_ASSETS.md` hinzugefügt.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
|
||||
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
|
||||
|
||||
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
|
||||
- **Beobachtete Probleme:**
|
||||
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
|
||||
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
|
||||
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
|
||||
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
|
||||
- **Kurzfristige Empfehlungen (nicht ausführen):**
|
||||
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
|
||||
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
|
||||
|
||||
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
|
||||
|
||||
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
|
||||
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
|
||||
- **Offene Test‑To‑Dos:**
|
||||
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
|
||||
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
|
||||
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||
- Token-Modell:
|
||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||
- Backend-Arbeitspaket:
|
||||
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||
- Android-Arbeitspaket:
|
||||
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||
- Sicherheitsregeln:
|
||||
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
|
||||
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
|
||||
|
||||
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
|
||||
|
||||
Kurzüberblick (3 Phasen):
|
||||
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
|
||||
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
|
||||
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
|
||||
|
||||
Detaillierte Aufgaben (priorisiert):
|
||||
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
|
||||
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
|
||||
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
|
||||
- B1: News-Management
|
||||
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
|
||||
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
|
||||
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
|
||||
- B2: Benutzer-Management
|
||||
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
|
||||
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
|
||||
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
|
||||
- B3: Kontaktanfragen
|
||||
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
|
||||
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
|
||||
- B4: Newsletter
|
||||
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
|
||||
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
|
||||
- B5: Config / Seiten (Inhalte)
|
||||
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
|
||||
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
|
||||
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
|
||||
- B6: Diagnostics / Passwort-Reset-Diagnose
|
||||
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
|
||||
- C1: Offline-/Caching-Strategie
|
||||
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
|
||||
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
|
||||
- C2: Tests & CI
|
||||
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
|
||||
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
|
||||
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
|
||||
Minor UX-Verbesserungen (parallel möglich):
|
||||
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
|
||||
|
||||
Deliverables & Milestones:
|
||||
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
|
||||
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
|
||||
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
|
||||
|
||||
Zeitplanung (empfohlen):
|
||||
- Analyse: 2 Arbeitstage
|
||||
- MVP-Implementierung: 7–10 Arbeitstage
|
||||
- Hardening + Tests: 3–5 Arbeitstage
|
||||
|
||||
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
|
||||
|
||||
**TODO (zum Abhaken) — CMS-Implementierung**
|
||||
|
||||
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
|
||||
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
|
||||
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
|
||||
- [x] B1: News-Management
|
||||
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
|
||||
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
|
||||
- [x] B1.3: Unit-Tests für `NewsViewModel`
|
||||
|
||||
- [x] B2: Benutzer-Management
|
||||
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
|
||||
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
|
||||
- [x] B2.3: Tests für Pagination/Leere Listen
|
||||
|
||||
- [x] B3: Kontaktanfragen
|
||||
- [x] B3.1: Detailansicht + Antwort-Option
|
||||
- [x] B3.2: Status-Filter + Archiv
|
||||
|
||||
- [ ] B4: Newsletter
|
||||
- [x] B4.1: Entwurf → Senden Flow mit Preview
|
||||
- [x] B4.2: Gruppenverwaltung (CRUD)
|
||||
|
||||
- [x] B5: Config / Seiten
|
||||
- Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor.
|
||||
- Android‑Status: Implementiert — die Android‑App enthält native CMS‑Screens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/Load‑Flows via `CmsViewModel`.
|
||||
- Umsetzung (B5.x):
|
||||
- [x] B5.1: `cms/startseite` (Startseiten‑Layout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
|
||||
- [x] B5.2: `cms/vereinsmeisterschaften` — CSV‑Parser/CSV‑Save integration and modal CRUD (native UI present).
|
||||
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
|
||||
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
|
||||
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
|
||||
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
|
||||
|
||||
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
|
||||
- Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
|
||||
- Android‑Status: umgesetzt — native Diagnose‑UI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`‑Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und Share‑Export der maskierten Logs.
|
||||
- Konkrete Android‑ToDos (B6.x):
|
||||
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details.
|
||||
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten.
|
||||
|
||||
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
|
||||
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
|
||||
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
|
||||
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
|
||||
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
|
||||
- [x] C2: Tests & CI
|
||||
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
|
||||
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
|
||||
- [x] C2.2: Compose-UI-Tests für kritische Flows
|
||||
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
|
||||
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
|
||||
|
||||
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.
|
||||
|
||||
84
ANDROID_PORT_TODO.md
Normal file
84
ANDROID_PORT_TODO.md
Normal file
@@ -0,0 +1,84 @@
|
||||
ANDROID App - 1:1 Portierung der Web-UI (TODO)
|
||||
|
||||
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
|
||||
|
||||
1) Analyse Codebasis & Assets
|
||||
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
|
||||
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
|
||||
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
|
||||
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
|
||||
|
||||
2) Projektziele und Scope
|
||||
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
|
||||
- Priorisiere Features für MVP vs. Post-Launch.
|
||||
|
||||
3) Designsystem und Farben extrahieren
|
||||
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
|
||||
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
|
||||
|
||||
4) Technologie-Stack wählen
|
||||
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
|
||||
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
|
||||
|
||||
5) Android-Projekt aufsetzen
|
||||
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
|
||||
|
||||
6) Theme & Farben implementieren
|
||||
- Implementiere App-Theme mit Farben/Typografie-Token.
|
||||
|
||||
7) Navigation-Struktur implementieren
|
||||
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
|
||||
|
||||
8) Screens für Seiten anlegen
|
||||
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
|
||||
|
||||
9) UI-Komponenten portieren
|
||||
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
|
||||
|
||||
10) Formulare & Validierung implementieren
|
||||
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
|
||||
|
||||
11) Authentifizierungs-Flow implementieren
|
||||
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
|
||||
|
||||
12) API-Client implementieren
|
||||
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
|
||||
|
||||
13) Bilderupload & Storage einrichten
|
||||
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
|
||||
|
||||
14) Offline-Support und Caching
|
||||
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
|
||||
|
||||
15) Lokalisierung und Texte prüfen
|
||||
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
|
||||
|
||||
16) Accessibility-Prüfung und Anpassungen
|
||||
- Farbkontrast, Touch-Targets, Screenreader-Labels.
|
||||
|
||||
17) Unit- und UI-Tests schreiben
|
||||
- Komponenten- und Integrations-Tests, E2E (falls möglich).
|
||||
|
||||
18) Performance-Optimierung durchführen
|
||||
- Bilder, Netzwerk, Render-Perf.
|
||||
|
||||
19) CI/CD für Builds einrichten
|
||||
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
|
||||
|
||||
20) Play Store Release vorbereiten
|
||||
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
|
||||
|
||||
21) Monitoring & Crash-Reporting einrichten
|
||||
- Sentry / Firebase Crashlytics, Analytics.
|
||||
|
||||
22) Dokumentation: Setup & Architektur
|
||||
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
|
||||
|
||||
23) Design Review und Abnahme
|
||||
- UX/Design-Review mit Stakeholdern.
|
||||
|
||||
24) Launch und Feedbackrunde durchführen
|
||||
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
|
||||
|
||||
|
||||
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).
|
||||
58
ANDROID_REPO_ENDPOINTS.md
Normal file
58
ANDROID_REPO_ENDPOINTS.md
Normal file
@@ -0,0 +1,58 @@
|
||||
Repo API Endpoints — Übersicht
|
||||
|
||||
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
|
||||
|
||||
Öffentliche/Frontend-Endpunkte (häufig genutzt):
|
||||
- GET /api/config
|
||||
- GET /api/news-public
|
||||
- GET /api/news
|
||||
- GET /api/termine
|
||||
- GET /api/spielplaene
|
||||
- GET /api/spielplan
|
||||
- GET /api/mannschaften
|
||||
- GET /api/galerie
|
||||
- GET /api/media/galerie/{id}
|
||||
- GET /api/personen/{filename}?width=...&height=...
|
||||
- POST /api/contact
|
||||
- POST /api/news (CMS)
|
||||
|
||||
Galerie / Media:
|
||||
- POST /api/galerie/upload
|
||||
- GET /api/galerie/list
|
||||
- GET /api/galerie/[id]
|
||||
- DELETE /api/galerie/[id]
|
||||
|
||||
Authentifizierung:
|
||||
- POST /api/auth/login
|
||||
- POST /api/auth/logout
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/reset-password
|
||||
- GET /api/auth/status
|
||||
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
|
||||
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
|
||||
|
||||
CMS / geschützte Endpunkte (erfordern Auth):
|
||||
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
|
||||
- POST /api/cms/save-csv
|
||||
- POST /api/cms/upload-spielplan-pdf
|
||||
- POST /api/cms/satzung-upload
|
||||
- POST /api/members, DELETE /api/members, POST /api/members/bulk
|
||||
- POST /api/membership/update-status
|
||||
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
|
||||
|
||||
Weitere (Datei-Uploads, Personen):
|
||||
- POST /api/personen/upload
|
||||
- GET /api/app/version
|
||||
- Various CMS-specific routes under /api/cms
|
||||
|
||||
Auth-Anforderungen & Hinweise:
|
||||
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
|
||||
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
|
||||
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
|
||||
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
|
||||
|
||||
Empfehlung für Android-Client:
|
||||
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
|
||||
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
|
||||
|
||||
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).
|
||||
70
android-app/PLAYSTORE_ASSETS.md
Normal file
70
android-app/PLAYSTORE_ASSETS.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Play Store Assets - Harheimer TC Android
|
||||
|
||||
## 1) Datenschutzerklaerung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/datenschutz
|
||||
|
||||
Die Seite ist in der Web-App als eigene Route vorhanden.
|
||||
|
||||
## 1b) Konto-Loeschung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/konto-loeschen
|
||||
|
||||
Die Seite beschreibt den Loeschprozess und Kontaktweg fuer App- und Webkonto.
|
||||
|
||||
## 2) Logo / Grafiken
|
||||
|
||||
### Pflicht
|
||||
- App-Icon (Play): 512 x 512 PNG
|
||||
|
||||
### Optional, aber empfohlen
|
||||
- Feature Graphic: 1024 x 500 PNG
|
||||
|
||||
### Generierung
|
||||
|
||||
Im Repo ist ein Script vorhanden, das aus dem Vereinslogo fertige Dateien erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-assets.sh
|
||||
```
|
||||
|
||||
Ausgabe in:
|
||||
- android-app/playstore-assets/generated/playstore-icon-512.png
|
||||
- android-app/playstore-assets/generated/playstore-feature-graphic-1024x500.png
|
||||
|
||||
## 3) Screenshots (anonymisiert)
|
||||
|
||||
### Grobe Anforderungen (Telefon)
|
||||
- Mindestens 2 Screenshots
|
||||
- PNG oder JPEG
|
||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||
|
||||
Empfehlung fuer Android-Phone:
|
||||
- 1080 x 1920 (Portrait)
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
Script fuer schwarze halbtransparente Balken ueber sensible Bereiche:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh <input.png> <output.png> 'x,y,w,h;x,y,w,h'
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh \
|
||||
android-app/playstore-assets/raw/screen1.png \
|
||||
android-app/playstore-assets/anon/screen1-anon.png \
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
||||
234
android-app/app/build.gradle.kts
Normal file
234
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,234 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||
.orElse("https://harheimertc.de/")
|
||||
.get()
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
.get()
|
||||
val androidVersionCode = providers.gradleProperty("ANDROID_VERSION_CODE")
|
||||
.orElse("2")
|
||||
.get()
|
||||
.toInt()
|
||||
val androidVersionName = providers.gradleProperty("ANDROID_VERSION_NAME")
|
||||
.orElse("1.0.0")
|
||||
.get()
|
||||
val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = androidVersionCode
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
storeFile = file(releaseStoreFile!!)
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = releaseMinifyEnabled
|
||||
isShrinkResources = false
|
||||
ndk {
|
||||
// Generate a native debug symbols archive for Play Console uploads.
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("local") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
create("instantTest") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".test"
|
||||
versionNameSuffix = "-test"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
// allow Byte Buddy experimental features for newer JVMs
|
||||
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("packageNativeDebugSymbolsForProductionRelease") {
|
||||
group = "distribution"
|
||||
description = "Packages production release native libraries into a Play Console debug symbols zip."
|
||||
dependsOn(":app:mergeProductionReleaseNativeLibs")
|
||||
from(layout.buildDirectory.dir("intermediates/merged_native_libs/productionRelease/mergeProductionReleaseNativeLibs/out/lib"))
|
||||
destinationDirectory.set(layout.buildDirectory.dir("outputs/native-debug-symbols/productionRelease"))
|
||||
archiveFileName.set("native-debug-symbols.zip")
|
||||
}
|
||||
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
doLast {
|
||||
val outputDir = layout.buildDirectory.dir("outputs/playstore/productionRelease").get().asFile
|
||||
outputDir.mkdirs()
|
||||
|
||||
val bundleFile = layout.buildDirectory.file("outputs/bundle/productionRelease/app-production-release.aab").get().asFile
|
||||
if (bundleFile.exists()) {
|
||||
bundleFile.copyTo(outputDir.resolve(bundleFile.name), overwrite = true)
|
||||
}
|
||||
|
||||
val mappingFile = layout.buildDirectory.file("outputs/mapping/productionRelease/mapping.txt").get().asFile
|
||||
if (mappingFile.exists()) {
|
||||
mappingFile.copyTo(outputDir.resolve("mapping.txt"), overwrite = true)
|
||||
}
|
||||
|
||||
val nativeSymbolsZip = layout.buildDirectory.file("outputs/native-debug-symbols/productionRelease/native-debug-symbols.zip").get().asFile
|
||||
if (nativeSymbolsZip.exists()) {
|
||||
nativeSymbolsZip.copyTo(outputDir.resolve("native-debug-symbols.zip"), overwrite = true)
|
||||
}
|
||||
|
||||
println("Play Store artifacts prepared in: ${outputDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui:1.5.0")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
|
||||
implementation("androidx.compose.material3:material3:1.1.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
||||
|
||||
// Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.59.2")
|
||||
ksp("com.google.dagger:hilt-compiler:2.59.2")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||
|
||||
// Passkeys / Credential Manager
|
||||
implementation("androidx.credentials:credentials:1.6.0")
|
||||
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
|
||||
|
||||
// Coil
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
// WorkManager, DataStore
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("io.mockk:mockk:1.13.7")
|
||||
// Compose UI testing
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
// Hilt testing
|
||||
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
|
||||
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
|
||||
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
|
||||
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
|
||||
}
|
||||
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/local/release/app-local-release.aab
Normal file
BIN
android-app/app/local/release/app-local-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/production/release/app-production-release.aab
Normal file
BIN
android-app/app/production/release/app-production-release.aab
Normal file
Binary file not shown.
2
android-app/app/proguard-rules.pro
vendored
Normal file
2
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Project-specific R8/ProGuard rules for release builds.
|
||||
# Keep this file intentionally minimal and add rules only when needed.
|
||||
7
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
7
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application android:allowBackup="false">
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
|
||||
// Kept as an empty placeholder to avoid accidental compilation of the previous
|
||||
// broken test module. Refer to TestHiltModules.kt for test bindings.
|
||||
package de.harheimertc.test
|
||||
|
||||
// Intentionally empty
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.harheimertc.test
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthUserDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import dagger.hilt.InstallIn
|
||||
import retrofit2.Response
|
||||
import javax.inject.Singleton
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
|
||||
)
|
||||
object TestHiltModules {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder().build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(): ApiService {
|
||||
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
|
||||
when (method.name) {
|
||||
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
|
||||
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
|
||||
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
|
||||
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
|
||||
private var token: String? = "test-token"
|
||||
private var refresh: String? = "r"
|
||||
override fun getToken(): String? = token
|
||||
override fun getRefreshToken(): String? = refresh
|
||||
override fun getSessionId(): String? = "s"
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
token = accessToken
|
||||
refresh = refreshToken
|
||||
}
|
||||
|
||||
override fun clearSession() { token = null; refresh = null }
|
||||
override fun ensureDeviceKey(): String? = null
|
||||
override fun getDevicePublicKey(): String? = null
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
|
||||
return LoginRepository(api, auth, sessionRefresher)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
|
||||
return PasskeyRepository(api, auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.harheimertc.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class TestActivity : ComponentActivity()
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsActivateResendTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun activateAndResend_buttonsAreClickable() {
|
||||
composeTestRule.setContent {
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
|
||||
}
|
||||
|
||||
// wait until nodes appear to avoid race conditions on slower devices
|
||||
fun waitForText(text: String, timeoutMs: Long = 15000L) {
|
||||
try {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// dump semantics tree for debugging before failing
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
|
||||
} catch (_: Throwable) { /* best-effort logging */ }
|
||||
throw AssertionError("Timed out waiting for text: '$text'")
|
||||
}
|
||||
}
|
||||
|
||||
// helper: find the nearest parent node that has a click action
|
||||
fun findClickableParent(text: String): SemanticsNodeInteraction {
|
||||
val all = composeTestRule.onAllNodes(hasText(text))
|
||||
if (all.fetchSemanticsNodes().isEmpty()) {
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No node found with text '$text'")
|
||||
}
|
||||
|
||||
// Log matches for debugging
|
||||
try {
|
||||
val matches = all.fetchSemanticsNodes()
|
||||
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
|
||||
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
|
||||
} catch (_: Throwable) { /* ignore logging failures */ }
|
||||
|
||||
var node = try {
|
||||
// prefer the single-node API, but fall back to the first match if ambiguous
|
||||
composeTestRule.onNode(hasText(text))
|
||||
} catch (_: AssertionError) {
|
||||
all[0]
|
||||
}
|
||||
|
||||
// climb a few parents to find the clickable wrapper
|
||||
repeat(8) {
|
||||
try {
|
||||
node.assert(hasClickAction())
|
||||
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
return node
|
||||
} catch (_: AssertionError) {
|
||||
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
node = node.onParent()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No clickable parent found for text '$text'")
|
||||
}
|
||||
|
||||
waitForText("Deaktivieren")
|
||||
val deactivateNode = findClickableParent("Deaktivieren")
|
||||
deactivateNode.assertExists()
|
||||
deactivateNode.assertIsDisplayed()
|
||||
deactivateNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
deactivateNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
|
||||
throw e
|
||||
}
|
||||
|
||||
waitForText("Invite erneut")
|
||||
val inviteNode = findClickableParent("Invite erneut")
|
||||
inviteNode.assertExists()
|
||||
inviteNode.assertIsDisplayed()
|
||||
inviteNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
inviteNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.data.MembershipResponse
|
||||
import de.harheimertc.data.NewsletterCreateRequest
|
||||
import de.harheimertc.data.NewsletterCreateResponse
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.NewsletterSendResponse
|
||||
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||
import de.harheimertc.data.NewsPublicResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||
import de.harheimertc.data.PasskeysResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.ProfileResponse
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import de.harheimertc.data.PublicGalleryImageDto
|
||||
import de.harheimertc.data.GalleryListResponse
|
||||
import de.harheimertc.data.GalleryUploadResponse
|
||||
import de.harheimertc.data.RefreshRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RemovePasskeyRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import de.harheimertc.data.TermineResponse
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsExistingScreensSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsDashboard_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsInhalte_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsVereinsmeisterschaften_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.saveCsvCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsSportbetrieb_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsEinstellungen_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsMitgliederverwaltung_clickFreischalten() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Freischalten").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserActiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsBenutzer_clickRollenSpeichern() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserRolesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsContactRequests_replySenden() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Antworten").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
|
||||
composeTestRule.onNodeWithText("Senden").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.replyContactCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsNewsletter_createAndSave() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
|
||||
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.createNewsletterCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsPasswordResetDiagnostics_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
|
||||
}
|
||||
|
||||
private fun createViewModel(api: RecordingApiService): CmsViewModel {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repository = CmsRepository(api, cache)
|
||||
return CmsViewModel(repository)
|
||||
}
|
||||
|
||||
private fun renderWithState(viewModel: CmsViewModel) {
|
||||
val readyState = CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
),
|
||||
contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
),
|
||||
newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
),
|
||||
newsletterGroups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
),
|
||||
passwordResetAttempts = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
),
|
||||
news = emptyList(),
|
||||
meisterschaften = listOf(
|
||||
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
|
||||
),
|
||||
)
|
||||
val field = CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingApiService : ApiService {
|
||||
var updateConfigCalls = 0
|
||||
var saveCsvCalls = 0
|
||||
var updateUserActiveCalls = 0
|
||||
var updateUserRolesCalls = 0
|
||||
var replyContactCalls = 0
|
||||
var createNewsletterCalls = 0
|
||||
|
||||
private val config = ConfigResponse()
|
||||
private val users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
)
|
||||
private val contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
)
|
||||
private val newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
)
|
||||
private val groups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
)
|
||||
private val diagnostics = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
)
|
||||
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
|
||||
updateConfigCalls++
|
||||
return Response.success(request)
|
||||
}
|
||||
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> =
|
||||
Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\"".toResponseBody(null))
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
|
||||
saveCsvCalls++
|
||||
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
|
||||
}
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
|
||||
updateUserRolesCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
|
||||
}
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
|
||||
updateUserActiveCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
|
||||
}
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
|
||||
replyContactCalls++
|
||||
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
|
||||
}
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
|
||||
createNewsletterCalls++
|
||||
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
|
||||
}
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsPasswordResetDiagnosticsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
|
||||
val api = createDiagnosticsApiService()
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repo = CmsRepository(api, cache)
|
||||
val viewModel = CmsViewModel(repo)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.waitUntil(15_000) {
|
||||
try {
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
|
||||
|
||||
// Trigger a manual refresh to validate the main interaction path.
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun createDiagnosticsApiService(): ApiService {
|
||||
val attempt = PasswordResetAttemptDto(
|
||||
requestId = "req-1",
|
||||
startedAt = "2026-05-29T10:15:00Z",
|
||||
emailMasked = "m***@example.com",
|
||||
ip = "127.0.0.1",
|
||||
failed = true,
|
||||
steps = listOf(
|
||||
PasswordResetStepDto(
|
||||
ts = "2026-05-29T10:15:01Z",
|
||||
step = "mail_configuration",
|
||||
status = "failed",
|
||||
reason = "smtp_credentials_missing",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val handler = InvocationHandler { _, method: Method, _ ->
|
||||
when (method.name) {
|
||||
"config" -> Response.success(ConfigResponse())
|
||||
"users" -> Response.success(CmsUsersResponse())
|
||||
"cmsUsers" -> Response.success(CmsUsersResponse())
|
||||
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
|
||||
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
|
||||
"passwordResetDiagnostics" -> Response.success(
|
||||
PasswordResetDiagnosticsResponse(
|
||||
retentionHours = 72,
|
||||
searchedEmail = "",
|
||||
matchingUsers = listOf(
|
||||
de.harheimertc.data.PasswordResetMatchingUserDto(
|
||||
id = "u1",
|
||||
name = "Max Muster",
|
||||
email = "max@example.com",
|
||||
active = true,
|
||||
),
|
||||
),
|
||||
attempts = listOf(attempt),
|
||||
),
|
||||
)
|
||||
"vereinsmeisterschaften" -> Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n".toResponseBody(null))
|
||||
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsRolesDialogTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
class FakeVm {
|
||||
var calledId: String? = null
|
||||
var calledRoles: List<String>? = null
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
calledId = id
|
||||
calledRoles = roles
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rolesDialog_callsUpdateUserRoles() {
|
||||
val fake = FakeVm()
|
||||
val initialRoles = listOf("admin")
|
||||
|
||||
composeTestRule.setContent {
|
||||
val show = remember { mutableStateOf(false) }
|
||||
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
|
||||
Column {
|
||||
Button(onClick = { show.value = true }) { Text("Rollen") }
|
||||
if (show.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { show.value = false },
|
||||
title = { Text("Rollen bearbeiten") },
|
||||
text = {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
// simple checkbox row for admin only (representative)
|
||||
Row {
|
||||
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
|
||||
if (checked) selected.add("admin") else selected.remove("admin")
|
||||
})
|
||||
Text("admin", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
fake.updateUserRoles("42", selected.toList())
|
||||
show.value = false
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open dialog
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
// Save immediately (we keep admin preselected)
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
|
||||
assert(fake.calledId == "42")
|
||||
assert(fake.calledRoles?.contains("admin") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsScreen_placeholder() {
|
||||
composeTestRule.setContent {
|
||||
Text("CMS Placeholder")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import de.harheimertc.data.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsStartseiteSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_rendersWithDefaultState() {
|
||||
// prepare a minimal fake ApiService that returns empty/neutral responses
|
||||
val fakeApi = object : ApiService {
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
|
||||
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
|
||||
}
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
val moshi = Moshi.Builder().build()
|
||||
val cache = SecureOfflineCache(context, moshi)
|
||||
val repo = CmsRepository(fakeApi, cache)
|
||||
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
|
||||
|
||||
// set a ready state to avoid waiting for async network loads in Vm.init
|
||||
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = emptyList(),
|
||||
contactRequests = emptyList(),
|
||||
newsletters = emptyList(),
|
||||
newsletterGroups = emptyList(),
|
||||
passwordResetAttempts = emptyList(),
|
||||
news = emptyList(),
|
||||
)
|
||||
try {
|
||||
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
val current = field.get(vm) as? MutableStateFlow<*>
|
||||
if (current is MutableStateFlow<*>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
|
||||
}
|
||||
} catch (_: Throwable) { /* best-effort, continue */ }
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
|
||||
}
|
||||
|
||||
// dump semantics tree for debugging
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
|
||||
} catch (_: Throwable) { }
|
||||
|
||||
// wait for the main title and info rows to appear
|
||||
fun waitForText(text: String, timeoutMs: Long = 20000L) {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) { false }
|
||||
}
|
||||
}
|
||||
|
||||
waitForText("Startseite")
|
||||
waitForText("Öffentliche")
|
||||
|
||||
// basic assertions (use substring matching)
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsUiAutomatorClickTest {
|
||||
|
||||
@Test
|
||||
fun clickThroughExistingCmsPages_andTrySave() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val device = UiDevice.getInstance(instrumentation)
|
||||
val packageName = "de.harheimertc.local"
|
||||
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
|
||||
context.startActivity(launchIntent)
|
||||
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
|
||||
|
||||
clickText(device, "Intern")
|
||||
clickText(device, "CMS")
|
||||
|
||||
openCmsCard(device, "Startseite")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Inhalte")
|
||||
clickIfPresent(device, "Inhalte speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Vereinsmeisterschaften")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Sportbetrieb")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Einstellungen")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Mitgliederverwaltung")
|
||||
clickIfPresent(device, "Freischalten")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Kontaktanfragen")
|
||||
clickIfPresent(device, "Antworten")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Newsletter")
|
||||
clickIfPresent(device, "Newsletter erstellen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
if (openCmsCardIfAvailable(device, "Benutzer")) {
|
||||
clickIfPresent(device, "Rollen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
}
|
||||
|
||||
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
|
||||
|
||||
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
|
||||
}
|
||||
|
||||
private fun openCmsCard(device: UiDevice, label: String) {
|
||||
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
|
||||
clickText(device, label)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
|
||||
if (clickIfPresent(device, label, 1500)) return true
|
||||
return clickTextWithScroll(device, label)
|
||||
}
|
||||
|
||||
private fun backToCmsDashboard(device: UiDevice) {
|
||||
if (!clickIfPresent(device, "CMS", 3000)) {
|
||||
device.pressBack()
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
requireNotNull(obj) { "Text nicht gefunden: $text" }
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return obj
|
||||
}
|
||||
|
||||
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
if (obj != null) {
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
|
||||
if (clickIfPresent(device, text, 1500)) return true
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GalleryScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun galleryScreen_rendersPlaceholder() {
|
||||
composeTestRule.setContent {
|
||||
GalleryScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun homeScreen_renders() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
HomeScreen(navController = navController, showNavigationHeader = false)
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun loginScreen_showsFields() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
LoginScreen(navController = navController, showBackNavigation = false)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
}
|
||||
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Disable Sentry automatic initialization in debug/test builds -->
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
<meta-data android:name="io.sentry.dsn" android:value="" />
|
||||
</application>
|
||||
</manifest>
|
||||
29
android-app/app/src/main/AndroidManifest.xml
Normal file
29
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
import android.util.Log
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
|
||||
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
|
||||
options.isEnableAutoSessionTracking = true
|
||||
options.tracesSampleRate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader =
|
||||
ImageLoader.Builder(this)
|
||||
.okHttpClient(okHttpClient)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.20)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(75L * 1024L * 1024L)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
44
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
44
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewMain() {
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccessTokenAuthenticator @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val currentAccessToken = response.request.header("Authorization")
|
||||
?.removePrefix("Bearer ")
|
||||
?.takeIf(String::isNotBlank)
|
||||
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var current: Response? = response
|
||||
var count = 0
|
||||
while (current != null) {
|
||||
count += 1
|
||||
current = current.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
724
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
724
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
@@ -0,0 +1,724 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
val titel: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val kategorie: String? = null,
|
||||
)
|
||||
data class SpielplanResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val data: List<SpielDto> = emptyList(),
|
||||
val headers: List<String> = emptyList(),
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
|
||||
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
|
||||
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
|
||||
@param:Json(name = "Altersklasse") val altersklasse: String = "",
|
||||
@param:Json(name = "Liga") val liga: String = "",
|
||||
@param:Json(name = "Staffel") val staffel: String = "",
|
||||
@param:Json(name = "Runde") val runde: String? = null,
|
||||
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
|
||||
@param:Json(name = "SpieleGast") val spieleGast: String = "",
|
||||
)
|
||||
data class TeamTableResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val season: String? = null,
|
||||
val table: TeamTableDto? = null,
|
||||
)
|
||||
data class TeamTableDto(
|
||||
val teamName: String = "",
|
||||
val leagueName: String = "",
|
||||
val table: LeagueTableDto? = null,
|
||||
)
|
||||
data class LeagueTableDto(
|
||||
val leagueTable: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
data class LeagueTableRowDto(
|
||||
@param:Json(name = "table_rank") val rank: Int? = null,
|
||||
@param:Json(name = "team_name") val teamName: String = "",
|
||||
@param:Json(name = "meetings_count") val meetings: Int? = null,
|
||||
@param:Json(name = "meetings_won") val won: Int? = null,
|
||||
@param:Json(name = "meetings_tie") val tied: Int? = null,
|
||||
@param:Json(name = "meetings_lost") val lost: Int? = null,
|
||||
@param:Json(name = "sets_won") val setsWon: Int? = null,
|
||||
@param:Json(name = "sets_lost") val setsLost: Int? = null,
|
||||
@param:Json(name = "games_won") val gamesWon: Int? = null,
|
||||
@param:Json(name = "games_lost") val gamesLost: Int? = null,
|
||||
@param:Json(name = "points_won") val pointsWon: Int? = null,
|
||||
@param:Json(name = "points_lost") val pointsLost: Int? = null,
|
||||
@param:Json(name = "rise_fall_state") val movement: String? = null,
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
val updated: String? = null,
|
||||
val author: String? = null,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class NewsResponse(
|
||||
val success: Boolean = false,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: Int? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class PublicGalleryImageDto(
|
||||
val filename: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
data class GalleryImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
val uploadedAt: String? = null,
|
||||
val previewFilename: String? = null,
|
||||
)
|
||||
data class GalleryPaginationDto(
|
||||
val page: Int = 1,
|
||||
val perPage: Int = 10,
|
||||
val total: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
data class GalleryListResponse(
|
||||
val success: Boolean = false,
|
||||
val images: List<GalleryImageDto> = emptyList(),
|
||||
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
|
||||
)
|
||||
data class GalleryUploadImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
)
|
||||
data class GalleryUploadResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val image: GalleryUploadImageDto? = null,
|
||||
)
|
||||
data class MembershipRequest(
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val strasse: String,
|
||||
val plz: String,
|
||||
val ort: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String,
|
||||
val telefon_privat: String? = null,
|
||||
val telefon_mobil: String? = null,
|
||||
val mitgliedschaftsart: String,
|
||||
val lastschrift_erlaubt: Boolean,
|
||||
val kontoinhaber: String,
|
||||
val iban: String,
|
||||
val bic: String? = null,
|
||||
val bank: String? = null,
|
||||
val datenschutz_einverstanden: Boolean,
|
||||
val satzung_anerkannt: Boolean,
|
||||
)
|
||||
data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val client: String = "android",
|
||||
val deviceName: String = "Harheimer TC Android-App",
|
||||
)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
val name: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
)
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
data class LogoutRequest(val refreshToken: String? = null)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
data class SaveCsvRequest(
|
||||
val filename: String,
|
||||
val content: String,
|
||||
)
|
||||
data class SaveCsvResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val writtenTo: List<String> = emptyList(),
|
||||
val jsonWrittenTo: List<String> = emptyList(),
|
||||
)
|
||||
data class PasskeyAuthenticationOptionsRequest(
|
||||
val email: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyRegistrationOptionsRequest(
|
||||
val preferredAuthenticatorType: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyDto(
|
||||
val id: String = "",
|
||||
val credentialId: String = "",
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val name: String = "",
|
||||
)
|
||||
data class PasskeysResponse(
|
||||
val success: Boolean = false,
|
||||
val passkeys: List<PasskeyDto> = emptyList(),
|
||||
)
|
||||
data class RemovePasskeyRequest(val credentialId: String)
|
||||
data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val geburtsdatum: String = "",
|
||||
val visibility: ProfileVisibilityDto = ProfileVisibilityDto(),
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ProfileResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val user: ProfileUserDto? = null,
|
||||
)
|
||||
data class ProfileUpdateRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val visibility: ProfileVisibilityDto,
|
||||
val currentPassword: String? = null,
|
||||
val newPassword: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
val inDays: Int = 0,
|
||||
)
|
||||
data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class MemberDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val birthday: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val source: String = "",
|
||||
val notes: String = "",
|
||||
val hasLogin: Boolean = false,
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
val success: Boolean = false,
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
)
|
||||
data class RegistrationVisibility(val showBirthday: Boolean)
|
||||
data class RegistrationRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val password: String,
|
||||
val geburtsdatum: String,
|
||||
val visibility: RegistrationVisibility,
|
||||
)
|
||||
data class TrainingLocationDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
)
|
||||
data class TrainingTimeDto(
|
||||
val id: String = "",
|
||||
val tag: String = "",
|
||||
val von: String = "",
|
||||
val bis: String = "",
|
||||
val gruppe: String = "",
|
||||
val info: String? = null,
|
||||
)
|
||||
data class TrainingDto(
|
||||
val ort: TrainingLocationDto = TrainingLocationDto(),
|
||||
val zeiten: List<TrainingTimeDto> = emptyList(),
|
||||
)
|
||||
data class TrainerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val lizenz: String = "",
|
||||
val schwerpunkt: String = "",
|
||||
val zusatz: String? = null,
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class BoardMemberDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val telefon: String = "",
|
||||
val email: String = "",
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class VorstandDto(
|
||||
val vorsitzender: BoardMemberDto = BoardMemberDto(),
|
||||
val stellvertreter: BoardMemberDto = BoardMemberDto(),
|
||||
val kassenwart: BoardMemberDto = BoardMemberDto(),
|
||||
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
|
||||
val sportwart: BoardMemberDto = BoardMemberDto(),
|
||||
val jugendwart: BoardMemberDto = BoardMemberDto(),
|
||||
)
|
||||
data class VereinDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val useVorsitzenderAddress: Boolean = false,
|
||||
)
|
||||
data class WebsiteResponsibleDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val email: String = "",
|
||||
)
|
||||
data class WebsiteDto(
|
||||
val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(),
|
||||
)
|
||||
data class SatzungDto(
|
||||
val pdfUrl: String = "",
|
||||
val content: String = "",
|
||||
)
|
||||
data class MembershipTierDto(
|
||||
val id: String = "",
|
||||
val typ: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val preis: Int = 0,
|
||||
val features: List<String> = emptyList(),
|
||||
)
|
||||
data class LinkItemDto(
|
||||
val label: String = "",
|
||||
val href: String = "",
|
||||
val description: String = "",
|
||||
val id: String = "",
|
||||
)
|
||||
data class LinkSectionDto(
|
||||
val title: String = "",
|
||||
val items: List<LinkItemDto> = emptyList(),
|
||||
val id: String = "",
|
||||
)
|
||||
data class HomepageSectionDto(
|
||||
val id: String = "",
|
||||
val enabled: Boolean = true,
|
||||
val key: String? = null,
|
||||
val marker: String? = null,
|
||||
val config: HomepageSectionConfigDto? = null,
|
||||
)
|
||||
data class HomepageSectionConfigDto(
|
||||
val season: String? = null,
|
||||
val teamName: String? = null,
|
||||
val teamAgeGroup: String? = null,
|
||||
)
|
||||
data class HomepageDto(
|
||||
val sections: List<HomepageSectionDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
val ttRegeln: String = "",
|
||||
val satzung: SatzungDto = SatzungDto(),
|
||||
val links: String = "",
|
||||
val linksStructured: List<LinkSectionDto> = emptyList(),
|
||||
)
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
|
||||
val verein: VereinDto = VereinDto(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val website: WebsiteDto = WebsiteDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
val homepage: HomepageDto = HomepageDto(),
|
||||
)
|
||||
data class CmsUserDto(
|
||||
val id: String = "",
|
||||
val email: String? = null,
|
||||
val name: String = "",
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
val phone: String = "",
|
||||
val active: Boolean = true,
|
||||
val created: String? = null,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class CmsUsersResponse(val users: List<CmsUserDto> = emptyList())
|
||||
data class ContactRequestDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String? = null,
|
||||
val message: String = "",
|
||||
val status: String = "",
|
||||
val createdAt: String? = null,
|
||||
val repliedAt: String? = null,
|
||||
)
|
||||
data class NewsletterDto(
|
||||
val id: String = "",
|
||||
val subject: String = "",
|
||||
val title: String = "",
|
||||
val createdAt: String? = null,
|
||||
val sentAt: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
data class NewsletterListResponse(
|
||||
val success: Boolean = false,
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterCreateRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val targetGroup: String? = null,
|
||||
val sendToExternal: Boolean? = null,
|
||||
)
|
||||
|
||||
data class NewsletterCreateResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val newsletter: NewsletterDto? = null,
|
||||
)
|
||||
|
||||
data class NewsletterSendResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val stats: Map<String, Any>? = null,
|
||||
)
|
||||
data class NewsletterGroupDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val subscribers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
data class NewsletterGroupsResponse(
|
||||
val success: Boolean = false,
|
||||
val groups: List<NewsletterGroupDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterSubscriptionRequest(
|
||||
val groupId: String,
|
||||
val email: String,
|
||||
val name: String? = null,
|
||||
)
|
||||
data class PasswordResetStepDto(
|
||||
val ts: String? = null,
|
||||
val step: String = "",
|
||||
val status: String = "",
|
||||
val reason: String? = null,
|
||||
val errorCode: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
data class PasswordResetAttemptDto(
|
||||
val requestId: String = "",
|
||||
val startedAt: String? = null,
|
||||
val emailMasked: String? = null,
|
||||
val ip: String? = null,
|
||||
val failed: Boolean = false,
|
||||
val steps: List<PasswordResetStepDto> = emptyList(),
|
||||
)
|
||||
data class PasswordResetMatchingUserDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val active: Boolean = true,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val searchedEmail: String? = null,
|
||||
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||
|
||||
@GET("/api/galerie/list")
|
||||
suspend fun galerieList(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("perPage") perPage: Int = 60,
|
||||
): Response<GalleryListResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("/api/galerie/upload")
|
||||
suspend fun uploadGalleryImage(
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("title") title: RequestBody,
|
||||
@Part("description") description: RequestBody,
|
||||
@Part("isPublic") isPublic: RequestBody,
|
||||
): Response<GalleryUploadResponse>
|
||||
|
||||
@GET("/api/galerie")
|
||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||
|
||||
@GET("/api/termine")
|
||||
suspend fun termine(): Response<TermineResponse>
|
||||
|
||||
@GET("/api/spielplan")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@GET("/api/spielplan/table")
|
||||
suspend fun spielplanTable(
|
||||
@Query("team") team: String,
|
||||
@Query("season") season: String? = null,
|
||||
): Response<TeamTableResponse>
|
||||
|
||||
@GET("/api/news-public")
|
||||
suspend fun publicNews(): Response<NewsPublicResponse>
|
||||
|
||||
@GET("/api/news")
|
||||
suspend fun memberNews(): Response<NewsResponse>
|
||||
|
||||
@POST("/api/news")
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@PUT("/api/config")
|
||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||
|
||||
@GET("/data/spielsysteme.csv")
|
||||
suspend fun spielsysteme(): Response<ResponseBody>
|
||||
|
||||
@GET("/api/vereinsmeisterschaften")
|
||||
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||
|
||||
@POST("/api/cms/save-csv")
|
||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||
|
||||
@POST("/api/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@POST("/api/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/register")
|
||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/authentication-options")
|
||||
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/login")
|
||||
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/passkeys/list")
|
||||
suspend fun passkeys(): Response<PasskeysResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/registration-options")
|
||||
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/register")
|
||||
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/remove")
|
||||
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/profile")
|
||||
suspend fun profile(): Response<ProfileResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
data class MemberSaveRequest(
|
||||
val id: String? = null,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
)
|
||||
|
||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
|
||||
|
||||
@POST("/api/members")
|
||||
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/members")
|
||||
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/members/bulk")
|
||||
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
|
||||
|
||||
@POST("/api/members/toggle-mannschaftsspieler")
|
||||
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
|
||||
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
|
||||
|
||||
@PUT("/api/cms/users/update-roles")
|
||||
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/cms/users/update-active")
|
||||
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/cms/users/resend-invite")
|
||||
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
data class ContactReplyRequest(val message: String)
|
||||
|
||||
@POST("/api/cms/contact-requests/{id}/reply")
|
||||
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
|
||||
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/groups/create")
|
||||
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/newsletter/groups/{id}")
|
||||
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/newsletter/groups/{id}")
|
||||
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/create")
|
||||
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
|
||||
|
||||
@PUT("/api/newsletter/{id}")
|
||||
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
|
||||
|
||||
@POST("/api/newsletter/{id}/send")
|
||||
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
|
||||
|
||||
@DELETE("/api/newsletter/{id}")
|
||||
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/public-list")
|
||||
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/subscribe")
|
||||
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/unsubscribe-by-email")
|
||||
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/confirm")
|
||||
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/password-reset-diagnostics")
|
||||
suspend fun passwordResetDiagnostics(
|
||||
@Query("email") email: String? = null,
|
||||
@Query("failedOnly") failedOnly: Boolean = true,
|
||||
): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
val token = authRepository.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
object MediaTypes {
|
||||
val json = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import de.harheimertc.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.Cache
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Singleton
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHttpCache(@ApplicationContext context: Context): Cache =
|
||||
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
@ApplicationContext context: Context,
|
||||
authInterceptor: AuthInterceptor,
|
||||
accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||
cache: Cache,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (request.method == "GET" && !hasNetwork(context)) {
|
||||
val offlineRequest = request.newBuilder()
|
||||
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
|
||||
.build()
|
||||
chain.proceed(offlineRequest)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
.addNetworkInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
val request = response.request
|
||||
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
|
||||
response.newBuilder()
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
.authenticator(accessTokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
val runtimeBase = BuildConfig.API_BASE_URL
|
||||
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(runtimeBase)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||
|
||||
private fun hasNetwork(context: Context): Boolean {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val network = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SecureOfflineCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
const val KEY_CMS_USERS = "cms_users"
|
||||
const val KEY_CONTACT_REQUESTS = "contact_requests"
|
||||
const val KEY_NEWSLETTERS = "newsletters"
|
||||
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
|
||||
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
|
||||
const val TIMESTAMP_SUFFIX = "_ts"
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
|
||||
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
|
||||
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
|
||||
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putContactRequests(response: List<ContactRequestDto>) {
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
|
||||
preferences.edit()
|
||||
.putString(KEY_CONTACT_REQUESTS, json)
|
||||
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
|
||||
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
|
||||
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
|
||||
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
|
||||
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
|
||||
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
|
||||
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
|
||||
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
|
||||
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
|
||||
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun clearCmsProtectedCaches() {
|
||||
clear(
|
||||
KEY_CMS_CONFIG,
|
||||
KEY_CMS_USERS,
|
||||
KEY_CONTACT_REQUESTS,
|
||||
KEY_NEWSLETTERS,
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
KEY_MEMBER_NEWS,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
|
||||
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
|
||||
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
|
||||
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
|
||||
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
|
||||
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
|
||||
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
|
||||
|
||||
private fun <T> put(key: String, value: T, type: Class<T>) {
|
||||
val json = moshi.adapter(type).toJson(value)
|
||||
preferences.edit()
|
||||
.putString(key, json)
|
||||
.putLong(timestampKey(key), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
|
||||
if (isExpired(key, maxAgeMillis)) return null
|
||||
val json = preferences.getString(key, null) ?: return null
|
||||
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun clear(vararg keys: String) {
|
||||
val editor = preferences.edit()
|
||||
keys.forEach { key ->
|
||||
editor.remove(key)
|
||||
editor.remove(timestampKey(key))
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
|
||||
if (maxAgeMillis == null) return false
|
||||
val savedAt = preferences.getLong(timestampKey(key), 0L)
|
||||
if (savedAt <= 0L) return true
|
||||
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
|
||||
}
|
||||
|
||||
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionRefresher @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val client = OkHttpClient.Builder().build()
|
||||
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
|
||||
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
|
||||
|
||||
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
|
||||
refreshAccessTokenBlocking() != null
|
||||