import "dart:convert"; import "package:flutter/foundation.dart"; import "package:shared_preferences/shared_preferences.dart"; import "config.dart"; import "i18n.dart"; import "models.dart"; /// The phone can be pointed at more than one DriverVault API Server. One is the /// "home" server — the address this build ships with, or whatever the login /// screen's Server settings overrode it to — and any number of others are added /// by URL, which works because an API Server a phone can reach is /// internet-facing already. /// /// Each server is its own PocketBase with its own users, so a session cannot be /// carried across: every server holds its own token under its own key. Only one /// is active at a time and the whole app reads from it, so switching swaps the /// garage, the charging page and the settings together. /// /// Mirrors the web app's servers.js, down to the storage keys — the same three /// legacy ones are read once at boot so an upgrade doesn't sign anyone out or /// forget the server they had pointed the app at. const String kHomeServerId = "home"; /// One server the app can read from. An empty [url] means [kDefaultApiBase], /// which only the home entry is allowed — anything else without an address is /// unusable. class ServerEntry { final String id; String name; String url; ServerEntry({required this.id, this.name = "", this.url = ""}); bool get isHome => id == kHomeServerId; Map toJson() => {"id": id, "name": name, "url": url}; factory ServerEntry.fromJson(Map json) => ServerEntry( id: json["id"].toString(), name: (json["name"] ?? "").toString(), url: normalizeServerUrl((json["url"] ?? "").toString()), ); } /// What is held for a signed-in server: PocketBase's own token and the user it /// was minted for. class ServerSession { final String token; final AuthUser? user; const ServerSession(this.token, this.user); } /// A server address as typed. Trailing slashes go, and a bare origin gets /// "/api" appended — every API Server route lives under it, so entering just /// "https://garage.example.com" should work without the user knowing that. String normalizeServerUrl(String url) { final trimmed = url.trim().replaceAll(RegExp(r"/+$"), ""); if (trimmed.isEmpty) return ""; final parsed = Uri.tryParse(trimmed); if (parsed != null && parsed.hasScheme && parsed.hasAuthority && (parsed.path.isEmpty || parsed.path == "/")) { return "${parsed.scheme}://${parsed.authority}/api"; } return trimmed; } class ServerRegistry extends ChangeNotifier { static const _listKey = "cc_servers"; static const _activeKey = "cc_active_server"; static String _sessionKey(String id) => "cc_session_$id"; /// The active server's resolved base URL, written out for the Android Auto /// service to read (android/.../car/VaultStore.kt). The car screens run /// without a Flutter engine, so they read this store directly — and an /// untouched home entry carries no address of its own to read, its base being /// [kDefaultApiBase], a compile-time define nothing outside Dart can see. static const _activeBaseKey = "cc_active_base"; // Pre-multi-server keys, read once by [_migrateLegacy]. static const _legacyTokenKey = "cc_token"; static const _legacyUserKey = "cc_user"; static const _legacyServerKey = "cc_server_url"; final List list = []; String activeId = kHomeServerId; final Map _sessions = {}; // Captured once in [load] so every later write is a plain call rather than an // await: the registry is read on the way into every request, and a switch has // to be in effect the moment it returns. SharedPreferences? _prefs; /// Reads the stored servers and their sessions. Call once in main() before /// the first request; everything below is synchronous afterwards. Future load() async { final prefs = await SharedPreferences.getInstance(); _prefs = prefs; _migrateLegacy(); list ..clear() ..addAll(_loadList()); _sessions.clear(); for (final server in list) { final session = _loadSession(server.id); if (session != null) _sessions[server.id] = session; } final stored = prefs.getString(_activeKey); activeId = list.any((s) => s.id == stored) ? stored! : kHomeServerId; _syncActiveBase(); } /// Republishes the active server's base URL for the car screens. Called /// wherever which server is active, or where it answers, can have changed. void _syncActiveBase() => _prefs?.setString(_activeBaseKey, activeBase); List _loadList() { final raw = _prefs?.getString(_listKey); final decoded = raw == null ? null : _tryDecode(raw); final entries = []; if (decoded is List) { for (final item in decoded) { if (item is Map && item["id"] != null) { final entry = ServerEntry.fromJson(Map.from(item)); if (entry.isHome || entry.url.isNotEmpty) entries.add(entry); } } } if (!entries.any((s) => s.isHome)) { entries.insert(0, ServerEntry(id: kHomeServerId)); } return entries; } ServerSession? _loadSession(String id) { final raw = _prefs?.getString(_sessionKey(id)); final decoded = raw == null ? null : _tryDecode(raw); if (decoded is! Map || decoded["token"] == null) return null; final user = decoded["user"]; return ServerSession( decoded["token"].toString(), user is Map ? AuthUser.fromJson(Map.from(user)) : null, ); } /// Carries a pre-multi-server session onto the home server. Any override the /// user had set in the login screen's Server settings becomes home's URL, so /// the same server keeps answering after the upgrade. void _migrateLegacy() { final prefs = _prefs!; final token = prefs.getString(_legacyTokenKey); final legacyUrl = prefs.getString(_legacyServerKey); if (token == null && legacyUrl == null) return; if (token != null && prefs.getString(_sessionKey(kHomeServerId)) == null) { final rawUser = prefs.getString(_legacyUserKey); final user = rawUser == null ? null : _tryDecode(rawUser); prefs.setString( _sessionKey(kHomeServerId), jsonEncode({"token": token, "user": user is Map ? user : null}), ); } if (legacyUrl != null && legacyUrl.isNotEmpty && prefs.getString(_listKey) == null) { prefs.setString( _listKey, jsonEncode([ServerEntry(id: kHomeServerId, url: normalizeServerUrl(legacyUrl)).toJson()]), ); } prefs.remove(_legacyTokenKey); prefs.remove(_legacyUserKey); prefs.remove(_legacyServerKey); } void _saveList() => _prefs?.setString(_listKey, jsonEncode(list.map((s) => s.toJson()).toList())); // --- reading ------------------------------------------------------------- ServerEntry get active => byId(activeId) ?? (list.isNotEmpty ? list.first : ServerEntry(id: kHomeServerId)); ServerEntry? byId(String id) { for (final server in list) { if (server.id == id) return server; } return null; } /// The base URL requests against [server] go to. String baseFor(ServerEntry? server) { final url = server?.url.trim() ?? ""; return url.isEmpty ? kDefaultApiBase : url; } String get activeBase => baseFor(active); ServerSession? sessionFor(String id) => _sessions[id]; ServerSession? get activeSession => _sessions[activeId]; String? get activeToken => activeSession?.token; bool isConnected(String id) => _sessions[id] != null; /// What to call a server in the UI: the name it was given, else the host it /// answers on, else — for an untouched home entry — a generic label. String displayName(ServerEntry? server) { if (server == null) return ""; if (server.name.isNotEmpty) return server.name; if (server.url.isEmpty) return t("servers.home"); final host = Uri.tryParse(server.url)?.host ?? ""; return host.isEmpty ? server.url : host; } // --- writing ------------------------------------------------------------- void setSession(String id, String token, AuthUser? user) { _sessions[id] = ServerSession(token, user); _prefs?.setString( _sessionKey(id), jsonEncode({"token": token, "user": user?.toJson()}), ); notifyListeners(); } void clearSession(String id) { _sessions.remove(id); _prefs?.remove(_sessionKey(id)); notifyListeners(); } void clearAllSessions() { for (final server in list) { _sessions.remove(server.id); _prefs?.remove(_sessionKey(server.id)); } notifyListeners(); } void setActive(String id) { if (byId(id) == null || id == activeId) return; activeId = id; _prefs?.setString(_activeKey, id); _syncActiveBase(); notifyListeners(); } ServerEntry add({String name = "", String url = ""}) { final server = ServerEntry( id: "s${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}", name: name.trim(), url: normalizeServerUrl(url), ); list.add(server); _saveList(); notifyListeners(); return server; } ServerEntry? update(String id, {String? name, String? url}) { final server = byId(id); if (server == null) return null; if (name != null) server.name = name.trim(); if (url != null) { final next = normalizeServerUrl(url); // Moving a server to a different address invalidates the token held for // it: it was minted by the PocketBase behind the old one. if (next != server.url) clearSession(id); server.url = next; } _saveList(); _syncActiveBase(); notifyListeners(); return server; } /// Removing a server forgets its session too. Home cannot be removed — it is /// the address the app ships with, and there would be nothing left to fall /// back to. void remove(String id) { if (id == kHomeServerId) return; clearSession(id); list.removeWhere((s) => s.id == id); _saveList(); if (activeId == id) { activeId = kHomeServerId; _prefs?.setString(_activeKey, kHomeServerId); } _syncActiveBase(); notifyListeners(); } static dynamic _tryDecode(String raw) { try { return jsonDecode(raw); } catch (_) { return null; } } } /// App-wide singleton (mirrors biometricAuth in biometric.dart). final serverRegistry = ServerRegistry();