- Umgebungen (Name, API-URL, Keycloak-Issuer) kommen aus
assets/hl_server_config.json statt hartcodiertem BackendConfig.prod;
Testumgebung 192.168.1.9:3001 ist dort hinterlegt
- AppBloc laedt die Config, aktiviert die gespeicherte Wahl (Fallback
Default) und registriert das Networking; Wechsel persistiert per
SharedPreferences und registriert neu
- Bloc-Baum ist pro Umgebung gekeyt: beim Wechsel entstehen alle Blocs
mit dem neuen API-Client neu
- Login-Screen: Zahnrad oben rechts oeffnet Einstellungen mit Umgebungs-
Cards (Name + IP:Port); Hinweis auf aktive Umgebung unter dem Button
- Fahrzeugauswahl pro Umgebung getrennt (eigene DB = eigene Car-IDs)
- Tests fuer Config-Parsing, Auswahl-Fallback, Card-Wechsel, Re-Registrierung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kunde meldete haeufig 'Sitzung abgelaufen' und danach dauerhaft rotes
'Sitzung abgelaufen' bei den Zahlungsmethoden trotz erfolgreichem Login.
Token-Refresh:
- _performRefresh wertete JEDEN Fehler als tote Session (Logout + Refresh-
Token geloescht) - auch Mobilfunk-/VPN-Aussetzer. Jetzt gilt nur noch eine
echte Ablehnung durch Keycloak (OAuth invalid_grant o. ae.) als abgelaufen;
voruebergehende Fehler behalten die Session, nutzen den noch gueltigen
Access-Token weiter oder werfen AuthTemporarilyUnavailableException
- Interceptor schickt Requests nicht mehr tokenlos weiter (ergab 401 ->
irrefuehrendes 'Sitzung abgelaufen'), sondern bricht als Verbindungsfehler ab
- restoreSession verwirft den Refresh-Token bei fehlendem Netz nicht mehr
Zahlungsmethoden:
- Cubit lud nur einmal beim App-Start (vor dem Login -> 401) und blieb danach
fuer immer im Fehlerzustand. Jetzt Reload bei jedem Wechsel auf Authenticated
- Fehlerkarte in der Uebersicht hat einen 'Erneut laden'-Button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nach fehlgeschlagenem/timeout Login blieb der Browser beim naechsten Tap
zu: ein per UI-Timeout verwaister flutter_appauth-Flow blockierte nativ
('Connection already in progress').
- Provider dedupet login() (nur EIN authorizeAndExchangeCode gleichzeitig,
race-sicher via identical-Guard) + discardPendingLogin() gibt die Sperre
nach UI-Timeout frei, damit der naechste Tap frisch startet
- Best-Effort-Retry, falls der native Flow noch offen ist
- AuthBloc: discardPendingLogin() bei Timeout; kein zweiter Handler waehrend
Authenticating
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackendConfig.localDev nutzt jetzt die LAN-IP des Dev-Macs
(192.168.0.138) statt localhost. Notwendig zum Testen auf einem
realen Android-Gerät über WLAN. Auf dem iOS-Simulator
zurückwechseln oder per Build-Flag injizieren.
- AuthBloc.on<SessionExpiredEvent> wird zum No-Op (mit Log).
Begründung: die alten ERPframe-Repos rufen das nach jedem 401 auf,
weil ihr Cookie-Login serverseitig weg ist. Solange Phase D diese
Repos nicht ersetzt hat, wäre ein echter Logout daraus fatal —
der erste TourBloc-Load nach Keycloak-Login würde die Session
sofort wieder wegwerfen. Die legitime SessionExpired-Quelle bleibt
der Provider-Stream (Refresh-Failure).
- CarSelectionPage hat jetzt durchgehend eine AppBar (vorher nur
im 'wechseln'-Modus) plus ein Account-Popup oben rechts mit
Personalnummer + roter Abmelden-Aktion. Der Drawer ist sonst
nur an Home, und solange Cars-Loading per 401 blockt, kommt der
User ohne Pre-Home-Logout nicht raus.
Drei zusammenhängende Korrekturen, die den OIDC-Flow auf realen Geräten
durchgehen lassen:
1. taskAffinity="" raus aus MainActivity — sonst landet die
RedirectUriReceiverActivity beim Rücksprung aus Samsung Internet
Custom Tabs (FLAG_ACTIVITY_NEW_TASK) in einem zweiten Task und
zweitem App-Prozess, AppAuth findet seinen in-memory PKCE-State
nicht und meldet 'No stored state - unable to handle response'.
2. network_security_config.xml: base-config cleartextTrafficPermitted
statt einzelner localhost/10.0.2.2-Domains. Notwendig für Tests
gegen die LAN-IP des Dev-Macs (z.B. 192.168.x.x); AndroidConfig kann
keine IP-Wildcards. Klar als Dev-only markiert.
3. promptValues=['login'] auf der AuthorizationTokenRequest — verhindert
den Instant-SSO-Cookie-Redirect-Race, bei dem Chrome Custom Tabs
schliesst, bevor der Redirect-Intent ankommt; AppAuth wuerde sonst
'User cancelled flow' melden, obwohl der Nutzer nicht abgebrochen
hat. UX-mässig auch gewollt: jeder Login frisch (Account-Wechsel
am gleichen Geraet ist denkbar), Restore laeuft über den Refresh-
Token aus der Secure Storage.
Beobachtung: Nach 'flutter_secure_storage' frisch dazugepackt ohne
Cold-Restart kam eine MissingPluginException auf dem AuthBloc-Stream
durch (read auf channel plugins.it_nomads.com/flutter_secure_storage)
und hat den ganzen Bloc-Event-Loop mitgerissen.
Fix:
- KeycloakOidcTokenProvider.restoreSession / _persistRefreshToken /
logout fangen Plugin-Exceptions ab und loggen sie über debugPrint,
statt sie hochzureichen. Restore-Pfad endet sauber mit 'kein Restore
möglich', Login-Pfad hält den Token in Memory weiter.
- AuthBloc._handleRestore mit eigener try/catch als zweite Schutzschicht
für jeden anderen Fehler aus dem Provider.
Bestehender Cold-Restart-Workaround (App stoppen + flutter run) für die
ursprüngliche MissingPluginException bleibt natürlich nötig — diese
Änderung sorgt nur dafür, dass künftige Storage-Probleme (Keychain
zerschossen, Restore-Backup, …) nicht die Auth komplett killen.
App-Code:
- KeycloakOidcTokenProvider: PKCE-Login via flutter_appauth, Refresh via
Refresh-Token aus flutter_secure_storage, Session-Restore beim
App-Start, Logout.
- AuthSessionEvent als Provider→Bloc-Brücke (LoggedIn/LoggedOut/
SessionExpired) auf einem Broadcast-Stream.
- AuthBloc komplett umgebaut: nimmt jetzt den KeycloakOidcTokenProvider
statt UserInfoService, mappt eingehende Provider-Events auf eigene
Zustände. Authenticated.fromClaims() liest personalnummer + Name aus
dem ID-Token-Payload.
- LoginPage: kein Browser+Deep-Link mehr — Button feuert
LoginRequested, der Provider übernimmt den restlichen Flow.
- network_locator: produktiver KeycloakOidcTokenProvider, doppelt
registriert (KeycloakOidcTokenProvider für AuthBloc,
AuthTokenProvider für Interceptor).
- Auth-State trägt zusätzlich personalnummer/displayName/email; das
Legacy-User-Objekt + sessionId bleiben temporär drin, damit die
alten ERPframe-Services (Phase D) noch kompilieren.
Plattform-Setup:
- Android: appAuthRedirectScheme=holzleitner in build.gradle.kts,
NetworkSecurityConfig erlaubt HTTP zu localhost/10.0.2.2/127.0.0.1.
- iOS: holzleitner als URL-Scheme im Info.plist, ATS-Ausnahme für
localhost (HTTP-Keycloak im Dev-Setup).
Out of scope:
- Keine echte App-Run-Smoke — kommt mit dem User-Test.
- iOS-pod-install läuft beim ersten 'flutter run ios' automatisch.
- Old ERPframe-Services bleiben aktiv und werfen ab jetzt 401 (kein
Cookie-Session-Token mehr) — wird in Phase D entfernt.
OpenAPI-Generator-Setup:
- tool/generate_api_client.sh: Direkter Aufruf der openapi-generator-cli.jar
(Java-CLI statt Dart-build_runner-Integration — vermeidet die
analyzer-/source_gen-Version-Hölle mit json_serializable)
- tool/fetch_openapi_generator.sh: lädt die JAR (29 MB) nach (gitignored)
- openapi/holzleitner.json: Snapshot der Backend-Spec für reproduzierbare
Generation
- packages/holzleitner_api/: generiertes Dart-Sub-Package (built_value +
dio), per path-dep im Haupt-pubspec eingehängt
Netzwerk-Layer (lib/data/network/):
- BackendConfig: API- und Keycloak-Endpoints für Local-Dev (localhost
wegen Keycloak-iss-Claim).
- AuthTokenProvider-Schnittstelle.
- DevPasswordGrantTokenProvider: Phase-A-Provider via Keycloak
password-grant, Token-Caching mit Expiry-Check (Phase B ersetzt das
durch flutter_appauth PKCE).
- HolzleitnerAuthInterceptor: dynamischer Bearer-Inject pro Request.
- HolzleitnerApiFactory: baut die generierte HolzleitnerApi-Klasse
mit unserem Interceptor statt der vier Default-Auth-Interceptors.
- network_locator.registerNetworking(): get_it-Setup, in main() vor
runApp() aufgerufen.
Clean-Arch-Scaffolding (lib/data/, lib/domain/):
- Verzeichnisstruktur für Phase C+D angelegt (mapper/, repository/,
entity/, repository/) — befüllt sich in den Folge-Phasen.
Smoke-Test:
- tool/smoke_test_api.dart ruft /health (ungeschützt) und /me/cars
(mit Bearer) via generiertem Client — grün gegen lokales Backend.