import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../../pb_auth.dart'; import 'waypoint_mission.dart'; /// One route as the API Server reports it: a [spec], or a tombstone (null /// [spec]) left by a delete on some device. [updatedAt] is the /// last-writer-wins clock either way. class RemoteRoute { const RemoteRoute(this.id, this.updatedAt, this.spec); final String id; final DateTime updatedAt; final WaypointMissionSpec? spec; bool get deleted => spec == null; /// Reads one entry of the server's `/api/routes` view. Null when unusable. static RemoteRoute? fromView(Map v) { final String id = v['id'] as String? ?? ''; final DateTime? at = DateTime.tryParse(v['updatedAt'] as String? ?? ''); if (id.isEmpty || at == null) return null; if (v['deleted'] == true) return RemoteRoute(id, at, null); final Object? spec = v['spec']; if (spec is! Map) return null; try { final WaypointMissionSpec m = WaypointMissionSpec.fromJson(Map.from(spec)); return RemoteRoute(id, at, m.copyWith(id: id, updatedAt: m.updatedAt ?? at)); } catch (_) { return null; } } } /// The server half of the mission library. [ApiMissionRemote] is the real one. abstract class MissionRemote { /// Who the routes belong to; empty while signed out (nothing syncs). String get account; /// Every route the account owns, tombstones included. Future> pull(); /// Stores [m]. Returns the server's copy when it holds a newer one (the /// write was refused), else null. Future push(WaypointMissionSpec m); /// Tombstones route [id] as deleted at [at]. Returns the server's copy when /// the route was changed after [at] (the delete was refused), else null. Future pushDelete(String id, DateTime at); } /// What [mergeMissions] decided. class MissionMerge { const MissionMerge({required this.missions, required this.tombstones, required this.toPush, required this.toDelete}); /// The library after the merge, newest first. final List missions; /// Local deletes the server has not heard of yet. final Map tombstones; /// Local routes newer than the server's copy (or unknown to it). final List toPush; /// Local deletes newer than the server's live copy. final Map toDelete; } final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); /// Last-writer-wins merge of the device's library with the server's, per /// route id, by `updatedAt`. A delete counts as a write at the time it was /// made, on either side. On a tie the server wins. /// /// A route saved before it had an `updatedAt` is treated as the oldest /// possible write (and stamped so it can be pushed). MissionMerge mergeMissions({ required List local, required Map tombstones, required List remote, }) { final Map mine = { for (final WaypointMissionSpec m in local) m.id: m.updatedAt == null ? m.copyWith(updatedAt: _epoch) : m, }; final Map theirs = {for (final RemoteRoute r in remote) r.id: r}; final Map out = {}; final Map keepTombstones = {}; final List toPush = []; final Map toDelete = {}; final Set ids = {...mine.keys, ...tombstones.keys, ...theirs.keys}; for (final String id in ids) { final WaypointMissionSpec? l = mine[id]; final DateTime? t = tombstones[id]; final RemoteRoute? r = theirs[id]; // The device's own latest word on this route: a save, or a delete. final bool localDeleted = t != null && (l == null || !t.isBefore(l.updatedAt!)); final DateTime? localAt = localDeleted ? t : l?.updatedAt; if (r == null) { // The server never had it: push a save; a delete has nothing to undo. if (!localDeleted && l != null) { out[id] = l; toPush.add(l); } continue; } if (localAt == null || !localAt.isAfter(r.updatedAt)) { // The server's copy (or its delete) is the newest word. if (!r.deleted) out[id] = r.spec!; continue; } if (localDeleted) { if (!r.deleted) { keepTombstones[id] = t; toDelete[id] = t; } } else { out[id] = l!; toPush.add(l); } } final List missions = out.values.toList() ..sort((WaypointMissionSpec a, WaypointMissionSpec b) => b.updatedAt!.compareTo(a.updatedAt!)); return MissionMerge(missions: missions, tombstones: keepTombstones, toPush: toPush, toDelete: toDelete); } /// [MissionRemote] over the API Server's `/api/routes`, as the signed-in /// pilot ([auth]). class ApiMissionRemote implements MissionRemote { ApiMissionRemote({PbAuth? auth}) : _auth = auth; final PbAuth? _auth; PbAuth get _a => _auth ?? auth; static const Duration _timeout = Duration(seconds: 10); @override String get account => _a.isAuthed ? (_a.userEmail.isEmpty ? '_' : _a.userEmail) : ''; @override Future> pull() async { final (int status, Map body) = await _send('GET', '/api/routes?mine=1&includeDeleted=1'); if (status != 200) throw MissionSyncException(status, body); return [ for (final Object? v in (body['routes'] as List? ?? const [])) if (v is Map) if (RemoteRoute.fromView(Map.from(v)) case final RemoteRoute r) r, ]; } @override Future push(WaypointMissionSpec m) async { final (int status, Map body) = await _send('PUT', '/api/routes/${Uri.encodeComponent(m.id)}', body: m.toJson()); if (status == 200 || status == 201) return null; return _conflict(status, body); } @override Future pushDelete(String id, DateTime at) async { final String when = Uri.encodeQueryComponent(at.toUtc().toIso8601String()); final (int status, Map body) = await _send('DELETE', '/api/routes/${Uri.encodeComponent(id)}?at=$when'); // 404: the server never had it, which is as deleted as it gets. if (status == 200 || status == 404) return null; return _conflict(status, body); } RemoteRoute _conflict(int status, Map body) { final Object? route = body['route']; final RemoteRoute? r = status == 409 && route is Map ? RemoteRoute.fromView(Map.from(route)) : null; if (r == null) throw MissionSyncException(status, body); return r; } Future<(int, Map)> _send(String method, String path, {Object? body}) async { final HttpClient http = HttpClient()..connectionTimeout = _timeout; try { final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path')); req.headers.set(HttpHeaders.authorizationHeader, _a.token); if (body != null) { req.headers.contentType = ContentType.json; req.add(utf8.encode(jsonEncode(body))); } final HttpClientResponse resp = await req.close().timeout(_timeout); final String text = await resp.transform(utf8.decoder).join().timeout(_timeout); Map json = {}; try { final Object? decoded = text.isEmpty ? null : jsonDecode(text); if (decoded is Map) json = Map.from(decoded); } catch (_) {} return (resp.statusCode, json); } finally { http.close(force: true); } } } class MissionSyncException implements Exception { const MissionSyncException(this.status, this.body); final int status; final Map body; @override String toString() => (body['error'] ?? 'Route sync failed ($status)').toString(); }