The one place a service badge is worth reading is the car it belongs to, and that is the one place the app could not be opened: Android Auto runs no Flutter engine, so a Flutter app is simply absent from the head unit. The same APK now carries a second face — Car App Library templates the host draws itself, in Kotlin under android/app/src/main/kotlin/com/drivervault/phoneapp/car/. Two screens. The garage lists a car per row with its due badge on the second line, worst first, because the host renders only the first handful of rows and the car this list exists to mention is the overdue one rather than whichever was added first. A tap opens what that car has coming: the odometer, the next service, and the reminders the server holds for it — typed in and auto-derived from documents and the service schedule alike, in the order it sorted them. None of it is a second implementation of the app. VaultStore reads the session the phone signed in with — the active server's base and token — out of shared_preferences' own store, which both halves share, so a server switched on the phone is the server the car reads from with nothing to keep in step; only cc_active_base is new, because an untouched home entry carries no address of its own, its base being kDefaultApiBase, a compile-time define nothing outside Dart can see. CarStrings reads the same assets/i18n files by the same dot paths, so a badge on the head unit is the string format.dart already puts on the phone, in the language the account chose: of the 32 keys the car screens ask for, 28 are keys a phone screen already used, and only carApp.* is theirs. CarFormat is format.dart's twin — same date pattern and number grouping from the account's settings, same worst-of-date-and-km service badge. A new test reads the Kotlin for the keys it looks up and fails if any is missing from a language file, since the analyzer's reach stops at the Dart. It only reads. A screen you cannot type into is a poor place to edit a car and a driver is a poor person to ask, so VaultApi has no write in it to reach for by accident. Three things the README now says out loud. The service is declared IOT, the closest category the library defines for something that is a garage rather than a map or a media player, which matters to a store submission and not to a sideload. The app lock does not reach the head unit: the flag is in memory and the credentials behind it in encrypted storage, neither readable from the car service, and there is no fingerprint reader in a dashboard to satisfy it with. And the home charger is not on there — the chargers endpoint relays its plugin's payload verbatim with no shape to read, and the serial the control card is driven by is never persisted, so the car would have nothing to name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
10 KiB
Dart
307 lines
10 KiB
Dart
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<String, dynamic> toJson() => {"id": id, "name": name, "url": url};
|
|
|
|
factory ServerEntry.fromJson(Map<String, dynamic> 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<ServerEntry> list = [];
|
|
String activeId = kHomeServerId;
|
|
final Map<String, ServerSession> _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<void> 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<ServerEntry> _loadList() {
|
|
final raw = _prefs?.getString(_listKey);
|
|
final decoded = raw == null ? null : _tryDecode(raw);
|
|
final entries = <ServerEntry>[];
|
|
if (decoded is List) {
|
|
for (final item in decoded) {
|
|
if (item is Map && item["id"] != null) {
|
|
final entry = ServerEntry.fromJson(Map<String, dynamic>.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<String, dynamic>.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();
|