Mobile Architecture

Architecture of the GENIE.AI Flutter mobile app: layers, state, security, and platform integration.

Overview

The GENIE.AI mobile app (mobile/genie_ai_mobile/) is a Flutter client that wraps the public-sector RAG stack behind OIDC-authenticated chat. It targets Android and iOS phones, builds in release mode through Flutter flavors (dev / staging / e2e / itu), and exchanges every backend call through an AuthInterceptor that injects a Keycloak-issued Bearer token.

This document is the developer entry point. It covers how the app is layered, how state is managed, how authentication works at the wire level, and where to look in the source tree. Operational deployment is in Mobile Deployment Guide; the user-facing UI surface is in UI Component Inventory; end-user authentication behaviour is in User Authentication; chat streaming and feedback are in Chat Pipeline.

1. Tech stack

CategoryLibraryVersionWhere used
Language / frameworkDart / FlutterSDK ^3.10.8 (pubspec.yaml:10)All app code
State managementflutter_riverpod^3.0.0lib/services/auth/auth_providers.dart, all screens
HTTPhttp^1.6.0ApiClient base, OpenAPI generated client
Secure storageflutter_secure_storage^8.1.0TokenStorage (Keychain on iOS, EncryptedSharedPreferences on Android)
OIDCflutter_appauthlocal fork (pubspec.yaml:28-29)KeycloakService
Markdown renderingflutter_markdown^0.7.7Chat message bubbles
PDF exportpdf / printing^3.11.1 / ^5.13.1“Export PDF” action in chat
Connectivityconnectivity_plus^7.0.0ConnectivityService, offline banner
Deep linksapp_links^6.3.3Universal/App Links handlers
Loggingtalker, talker_flutter^4.5.0Structured auth_logger.dart events
OpenAPI clientgenerated Dartpath: openapi_clientlib/api/ — regenerated from backend JSDoc

The OpenAPI client (openapi_client/) is auto-generated from the backend JSDoc annotations and is checked in. Regenerate with ./scripts/generate-api-client.sh (see mobile/genie_ai_mobile/CLAUDE.md).

Local flutter_appauth fork — pubspec.yaml:28-29 references a vendored fork at flutter_appauth/flutter_appauth/. The fork exists to work around an upstream InsecureConnectionBuilder issue (tracked as flutter_appauth upstream issue #386). The fork is development-only: revert the path: override and drop the flutter_appauth/ directory before any merge to main — production uses CA-signed certs and allowInsecureConnections=false.

2. Application layers

The app follows a five-layer split that maps to directories under mobile/genie_ai_mobile/lib/:

LayerDirectoryResponsibilityPublic surface (example)
Configurationlib/config/Flavor switching, Keycloak / backend URLsKeycloakConfig, getConfig()
Serviceslib/services/Domain logic, secure storage, HTTP, OIDCAuthNotifier, KeycloakService, ConnectivityService
Providerslib/providers/Riverpod wiring of services and API clientsauthenticatedApiProvider, authProvider
Componentslib/components/Self-contained UI widgetsChatBotComponent, SidebarComponent, OidcLoginScreen
Design systemlib/design_system/Tokens, primitives, themeDsButton, appTheme.dart

Composition root lives in lib/main.dart (root widget MyApp, multi-provider scope). The lib/src/app.dart file is a Flutter project-template leftover (uses SettingsController, hardcodes a 3-locale list [en, de, ar], references sample_feature/ and settings/ views) and is not wired into the running app — see UI Component Inventory for the same “unused at runtime” note.

The codebase does not use a Proxy/Service-Provider pattern at the service layer. Domain classes (UserService, ConnectivityService, KeycloakService) are plain Dart classes; cross-cutting concerns (auth headers, logging, error classification) are wired by Riverpod providers and the AuthInterceptor, not by service proxies.

3. API layer

Provider wiring (lib/providers/api_providers.dart)

Riverpod exposes one provider per backend API domain. Each provider constructs a generated API client that shares the same authenticated http.Client injected by AuthInterceptor. Example shape (paraphrased from api_providers.dart):

final authenticatedApiProvider = Provider<ApiClient>((ref) {
  final config = getConfig();
  final tokenStorage = ref.watch(tokenStorageProvider);
  final client = config.allowInsecureConnections
      ? InsecureHttpClient()
      : http.Client();
  final interceptor = AuthInterceptor(
    inner: client,
    tokenStorage: tokenStorage,
    onRefreshToken: () => ref.read(authProvider.notifier).refreshToken(),
    logger: ref.read(authLoggerProvider),
  );
  return ApiClient(basePath: config.backendUrl)..client = interceptor;
});

This keeps the surface small and lets tests override a single provider.

Backend routes the app calls

Verified against components/gov-chat-backend/routes/:

RouteMethodAuthUsed for
/api/queries/streamPOSTyesSSE chat streaming (see Chat Pipeline)
/api/queries/POSTyesNon-streaming chat fallback
/api/queries/:id/feedbackPOSTyesPer-message feedback submission
/api/queries/:id/conversationPOSTyesSave conversation (title + messages)
/api/meGET / PUTyesProfile read / update
/api/me/reset-dataPOSTyesWipe per-user state (pre-delete)
/api/me/deletePOSTyesSelf-service account deletion
/api/auth/logoutPOSTyesBackend-side session invalidation

For full request/response shapes, error codes, and pagination, see API Contracts (backend) — that document is the source of truth.

4. State management (Riverpod)

Provider layout (lib/services/auth/auth_providers.dart)

ProviderTypeOwns
tokenStorageProviderProvider<TokenStorage>flutter_secure_storage-backed token persistence
authLoggerProviderProvider<AuthLogger>Structured auth event logging
keycloakServiceProviderProvider<KeycloakService>OIDC discovery + token endpoints
appAuthProviderProvider<AppAuth>flutter_appauth wrapper
authenticatedApiProviderProvider<ApiClient>Authenticated HTTP client with AuthInterceptor (in lib/providers/api_providers.dart)
authProviderNotifierProvider<AuthNotifier, AuthState>Top-level auth state machine
connectivityCheckerProviderProvider<ConnectivityChecker>Network state

AuthNotifier lifecycle (lib/services/auth/auth_notifier.dart)

AuthNotifier is the single authority for the auth state machine. The lifecycle has five entry points:

  1. init() — runs at app startup. Reads flutter_secure_storage, validates stored tokens, sets AuthState to authenticated if still valid, unauthenticated otherwise.
  2. authorize() — kicked off by the OIDC login screen. Discovers Keycloak via OIDC metadata, opens the system browser via flutter_appauth, exchanges the PKCE-verified authorization code for tokens, and persists them.
  3. refreshTokens() — called transparently when a backend call returns 401 (see User Authentication for the single-flight mutex).
  4. didChangeAppLifecycleState(state) — wired to Flutter’s WidgetsBindingObserver. When state == AppLifecycleState.resumed and the user is authenticated, runs validateTokens() (proactive expiry check on resume).
  5. logout() — fires the parallel logout sequence (see User Authentication).

_FailedOperation enum (auth_notifier.dart:41) tracks which operation last failed, so the UI can resume after a retry instead of asking the user to re-authorize.

AuthState (lib/services/auth/auth_state.dart)

enum AuthStatus { authenticated, unauthenticated, error }

class AuthState {
  final AuthStatus status;
  final String? userId;
  final String? displayName;
  final String? errorMessage;
  final bool retryable;

  const AuthState({
    this.status = AuthStatus.unauthenticated,
    this.userId,
    this.displayName,
    this.errorMessage,
    this.retryable = false,
  });

  const AuthState.authenticated({this.userId, this.displayName})
    : status = AuthStatus.authenticated,
      errorMessage = null,
      retryable = false;

  const AuthState.unauthenticated()
    : status = AuthStatus.unauthenticated,
      userId = null,
      displayName = null,
      errorMessage = null,
      retryable = false;

  AuthState.error({required String message, this.retryable = false})
    : status = AuthStatus.error,
      userId = null,
      displayName = null,
      errorMessage = message;
}

Widgets read authProvider and switch on state.status; the retryable flag controls whether a “Retry” button is shown.

5. Networking and SSE

AuthInterceptor (lib/services/auth/auth_interceptor.dart)

A BaseClient wrapper that:

  • On onRequest: reads the access token from TokenStorage and adds Authorization: Bearer <token>.
  • On onResponse: if the backend returns 401, attempts one transparent refresh, then retries the original request once. If the retry still fails with 401, throws AuthException so the UI can surface “Session expired”.

The refresh step is single-flight: while one refresh is in flight, parallel 401s wait on the same Completer<TokenSet?> rather than each issuing their own refresh call. This prevents Keycloak’s refresh-token rotation from invalidating a just-rotated token.

SSE parsing (lib/services/sse_parser.dart)

For the /api/queries/stream endpoint, the response is a text/event-stream. SseParser chunks the byte stream and emits five typed events (verbatim from sse_parser.dart:6-11):

Event classJSON type fieldPayloadTriggered by
SseChunkEventchunkcontent: stringEach incremental LLM token
SseMetadataEventmetadatasource docs, confidence scoreBackend finalises retrieval
SseTranslationEventtranslationcontent: stringStreaming translation swap (issue #829)
SseDoneEventdonequeryId: stringStream complete
SseErrorEventerrormessage: stringBackend stream-level error

The full taxonomy and downstream propagation are documented in Chat Pipeline.

6. Security features

FeatureImplementationReference
OIDC PKCE (S256)flutter_appauth with codeVerifier + codeChallenge; no client secret (public client, RFC 8252)KeycloakService.authorize()
Secure token storageflutter_secure_storage — iOS Keychain / Android EncryptedSharedPreferencesTokenStorage (services/auth/token_storage.dart)
Transparent refreshAuthInterceptor single-flight refresh on 401services/auth/auth_interceptor.dart
RP-initiated logoutend_session_endpoint call with id_token_hint + backend /api/auth/logout + flutter_secure_storage.deleteAll()auth_notifier.dart#logout()
Lifecycle-aware validationWidgetsBindingObserver.didChangeAppLifecycleState → validateTokens() on resumeauth_notifier.dart:564
Custom URL scheme gatingOIDC callback uses <redirectScheme>://callback registered through RedirectUriReceiverActivity (Android) / CFBundleURLSchemes (iOS); isolated from HTTPS trafficAndroidManifest.xml, Info.plist
Dev-only insecure HTTP clientinsecure_http_client.dart is never used in production; gated by allowInsecureConnections flag in flavor configlib/services/auth/insecure_http_client.dart

SSL pinning is not implemented in production builds. The custom HTTP client (insecure_http_client.dart) is a development convenience for self-signed certificates on local Keycloak. Production relies on the system trust store (CA-signed certs).

For the user-visible flow of these features — login screen tap, browser handover, refresh on 401, logout sequence, error classes — see User Authentication.

7. i18n

ItemValue
Shipped locales14 — ar, bn, zh, en, fr, de, id, man, pt, ru, st, es, sw, th
Source of truthlib/i18n/locales/<code>.dart (one file per locale)
Active set per flavorKeycloakConfig.supportedLocaleCodes (defaults to allSupportedLocaleCodes; e.g. el-salvador restricts to ['en','es'])
Selection at runtimeLanguageSelector (components/shared/language_selector.dart) → i18n_service.dart
Fallbackfallback_localizations.dart resolves missing keys to English

Deployment-level locale whitelist is config-driven, not file-presence-driven — all 14 locale files stay in the source. A flavor restricts the active set via KeycloakConfig.supportedLocaleCodes.

See Restrict active locales for the canonical deployer reference.

8. Cross-cutting services

ServicePurposeWhen it fires
ConnectivityServiceReactive online/offline stateDrives the offline banner and gates retry logic
ConnectivityCheckerThin wrapper around connectivity_plus for HTTP error classificationUsed inside NetworkErrorClassifier
NetworkErrorClassifierDistinguishes retryable (timeout, DNS) from terminal (TLS, 4xx) errorsAuthInterceptor error path
NotificationServiceIn-app toast/event bus driven by a broadcast StreamController. Push notifications are not currently wired.Drives in-app success/error/info/warning toasts
GenieAIConfigReads assets/config/genie-ai-config.json (institution-specific service list, taxonomy)Loaded at startup; drives Quick Help buttons
FallbackLocalizationsResolves missing translation keys to Englishi18n_service.dart lookups

9. Project layout

For the complete annotated directory tree — including the design system, providers, utils, and locale files — see UI Component Inventory. The single most important contract for contributors: never edit openapi_client/ by hand — regenerate it with ./scripts/generate-api-client.sh from the backend JSDoc.

10. Next steps

  • Shipping a build: see Mobile Deployment Guide — covers flavor creation, Android signing, iOS provisioning, App Links, and store submission.
  • End-user auth behaviour: see User Authentication — covers the PKCE flow, refresh-on-401, error classes, and logout sequence.
  • Chat UX: see Chat Pipeline — covers SSE event flow, feedback dialog, Quick Help overlay, PDF export.
  • Per-widget API: see UI Component Inventory.
  • Contributor workflows: see mobile/genie_ai_mobile/CLAUDE.md — OpenAPI regeneration, emulator SSL setup, E2E tests.