// Local state, split by owner: fumi's SQLite store owns everything the // protocol defines (mail, contacts, pins, accepted, tokens, seen ids), and // this Hive layer owns only what the app owns — the master secret, read // marks and UI settings. Reads are synchronous FFI calls (a query plus one // JSON parse, microseconds); network operations stay on the client, where // they run through isolates. import "dart:async"; import "dart:convert"; import "dart:io"; import "dart:typed_data"; import "package:hive/hive.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/ui.dart"; const tierMain = 0, tierRequests = 1; /// A displaced key a contact no longer uses, with when it stopped being /// current (epoch ms) — the record §8 turns on the user being able to see. class ContactHistoryEntry { final String key; final int until; const ContactHistoryEntry(this.key, this.until); } class StoredContact { final String key; final bool verified; final List history; const StoredContact(this.key, this.verified, this.history); } /// A correspondent admitted to the mailbox's main tier (§5.8). The identity /// is frozen at acceptance because the token is derived from it. class AcceptedContact { final String identity; final bool active; const AcceptedContact(this.identity, this.active); } class ImportSummary { int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0, contactsConflicted = 0, mailAdded = 0, malformed = 0; @override String toString() => "$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), " "$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed"; } class MailRecord { final String id; // hex of the 32-byte message id final String envelope; // sealed, base64 — plaintext is never at rest final int? receivedAt; final String? recipient; // sent copies only final int? sentAt; final int tier; // §5.8: tierMain or tierRequests; meaningless for sent final bool keptOnServer; const MailRecord(this.id, this.envelope, {this.receivedAt, this.recipient, this.sentAt, this.tier = tierMain, this.keptOnServer = false}); } /// The public half of the identity; the master never leaves the store /// except through the reveal-and-copy flow in settings. class SmolIdentity { final String publicKey; // base32 const SmolIdentity(this.publicKey); } class SmolStore { final Box _meta; final Box _read; final FumiNative _native; SmolStore._(this._meta, this._read, this._native); /// The SQLite file fumi's store owns. One store per process. late final String dbPath = _native.dbPath; /// Opens both layers. [dbPath] is the SQLite file fumi's store owns; the /// box names exist so tests can hold several isolated stores in one /// process. static Future open( {required String dbPath, String stateBox = "smol-state", String readBox = "smol-read"}) async { final native = FumiNative(dbPath); await native.open(); final store = SmolStore._( await Hive.openBox(stateBox), await Hive.openBox(readBox), native); final master = store.master(); if (master != null) { // The rotation index sits in the native store; read it through the // synchronous ABI, not the async wrapper. native.setMaster(master, rotations: SmolFfi.open().rotations(native.store!)); } return store; } /// The native binding this store fronts; the client drives its network /// operations, the store its reads. FumiNative get native => _native; SmolFfi get _ffi => SmolFfi.open(); // --- identity --------------------------------------------------------------- SmolIdentity? identity() { if (master() == null) return null; return SmolIdentity(_ffi.accountPk(_native.account!)); } /// The master, as the one mutable buffer that owns it — wipe overwrites /// these bytes rather than leaving them to the garbage collector, which /// is the honest version of zeroization Dart allows. Hive keeps the /// durable copy as bytes too; a hex string could never be scrubbed. Uint8List? master() { final stored = _meta.get("master"); if (stored == null) return null; if (stored is Uint8List) return stored; // The pre-swap app stored hex; convert once. Two alpha testers, so this // shim retires when their stores have moved. final bytes = unhex(stored as String); _meta.put("master", bytes); return bytes; } /// A fresh identity: the master at rotation index 0. Only this local step; /// nothing is sent until registration. void setMaster(Uint8List fresh) { // Hive's in-memory state updates synchronously and persists in the // background, so the store is consistent without awaiting the write. // The put takes its own copy: the caller's buffer is the caller's to // zeroize (the onboarding screen does, on dispose), and a shared object // would scrub the store's view with it. unawaited(_meta.put("master", Uint8List.fromList(fresh))); _native.setMaster(fresh, rotations: 0); } /// The master restored from a backup, already at the rotation index the /// server bound. void restoreMaster(Uint8List master, int index) { unawaited(_meta.put("master", Uint8List.fromList(master))); _native.setMaster(master, rotations: index); } int rotations() => _ffi.rotations(_native.store!); // --- settings --------------------------------------------------------------- bool leaveOnServer() => _meta.get("leaveOnServer") == true; Future setLeaveOnServer(bool value) async => _meta.put("leaveOnServer", value); bool syncOk() => _ffi.syncOk(_native.store!); // --- mail ------------------------------------------------------------------- List listMessages(String folder) { final rows = _ffi.mail(_native.store!, folder); return [ for (final row in rows.cast>()) MailRecord( row["id"] as String, row["envelope"] as String, receivedAt: folder == "sent" ? null : row["at"] as int, recipient: row["recipient"] as String?, sentAt: folder == "sent" ? row["at"] as int : null, tier: (row["tier"] as int?) ?? tierMain, keptOnServer: row["kept"] as bool? ?? false, ), ]..sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0) .compareTo(a.receivedAt ?? a.sentAt ?? 0)); } /// One message by folder and id, for the reader screen's deep link. MailRecord? getMessage(String folder, String id) { for (final row in listMessages(folder)) { if (row.id == id) return row; } return null; } bool isRead(String id) => _read.get(id) == true; void markRead(String id) => unawaited(_read.put(id, true)); int unreadCount() => _unread("inbox"); int requestsUnreadCount() => _unread("requests"); int _unread(String folder) { final rows = _ffi.mail(_native.store!, folder); return rows.cast>() .where((row) => _read.get(row["id"] as String) != true) .length; } /// The reader's delete: local removal with the id marked seen (§10), so a /// still-kept server copy is not re-stored by the next fetch. Future deleteMessage(String folder, String id) async => _native.deleteLocal(folder, [id]); // --- contacts --------------------------------------------------------------- List<(String, StoredContact)> allContacts() { final rows = _ffi.contacts(_native.store!); return [ for (final row in rows.cast>()) ( row["address"] as String, StoredContact( row["key"] as String, row["verified"] as bool, [ for (final entry in (row["history"] as List).cast>()) ContactHistoryEntry( entry["key"] as String, entry["until"] as int), ], ) ), ]; } StoredContact? contact(String address) { final row = _ffi.contact(_native.store!, address); if ((row["key"] as String).isEmpty) return null; return StoredContact( row["key"] as String, row["verified"] as bool, [ for (final entry in (row["history"] as List).cast>()) ContactHistoryEntry(entry["key"] as String, entry["until"] as int), ], ); } /// The acceptance state: main tier, blocked, or never accepted. AcceptedContact? accepted(String address) { final row = _ffi.contact(_native.store!, address); final active = row["active"] as bool?; final acceptedKey = row["acceptedKey"] as String?; if (active == null || acceptedKey == null) return null; return AcceptedContact(acceptedKey, active); } List<(String, AcceptedContact)> allAccepted() { final rows = _ffi.contacts(_native.store!); return [ for (final row in rows.cast>()) if (row["active"] != null && row["acceptedKey"] != null) ( row["address"] as String, AcceptedContact( row["acceptedKey"] as String, row["active"] as bool) ), ]; } /// Binds an address to a key. A different key displaces the old one into /// the contact's history (§8). Future saveContact(String address, String keyB32, {required bool verified}) async => _native.saveContact(address, keyB32, verified: verified); /// The address a key is known by, if any — naming a mailbox is not /// trusting a key, so nothing is bound here. String? addressForKey(String keyB32) { for (final (address, contact) in allContacts()) { if (contact.key == keyB32) return address; } return null; } // --- pins ------------------------------------------------------------------- /// Pins a server's static key (§4): sync, because it is one local write. void pinServer(String host, String keyB32) => _ffi.pinServer(_native.store!, host, keyB32); String? serverPin(String host) => _ffi.serverPin(_native.store!, host); List<(String, String)> allPins() { final rows = _ffi.pins(_native.store!); return [ for (final row in rows.cast>()) (row["host"] as String, row["key"] as String), ]; } Future unpinServer(String host) async => _native.unpinServer(host); // --- backups ---------------------------------------------------------------- /// The gsmol backup container: sealed mail, contacts and pins — never the /// master — as one JSON file body. Future exportData() => _native.exportBackup(); Future importData(String text) async { final summary = await _native.importBackup(text); final Map parsed; try { parsed = _decodeSummary(summary); } on Exception { throw const SmolError("not a gsmol export file"); } final out = ImportSummary(); out.pinsAdded = parsed["pinsAdded"] as int; out.pinsConflicted = parsed["pinsConflicted"] as int; out.contactsAdded = parsed["contactsAdded"] as int; out.contactsConflicted = parsed["contactsConflicted"] as int; out.mailAdded = parsed["mailAdded"] as int; out.malformed = parsed["malformed"] as int; return out; } Map _decodeSummary(String text) => jsonDecode(text) as Map; // --- teardown --------------------------------------------------------------- /// A full wipe: every secret, envelope and mark. The UI must confirm. /// The master's bytes are overwritten before their references go — Dart /// cannot promise zeroed immutable strings, so the master is only ever /// held as this one mutable buffer. Future wipe() async { master()?.fillRange(0, 32, 0); _native.clearMaster(); await _meta.delete("master"); await _read.clear(); await _meta.delete("master"); await _native.close(); try { final db = File(dbPath); if (await db.exists()) await db.delete(); for (final suffix in ["-wal", "-shm"]) { final side = File("$dbPath$suffix"); if (await side.exists()) await side.delete(); } } on FileSystemException { // A wipe must not fail on files the store never created. } await _native.open(); } }