feat(sync): device-to-device inventory sync, end to end

Wires the encrypted sync transport into a working replication between one
identity's own devices, reusing the CRDT-ready model (HLC + tombstones +
LWW) the data model was built for.

- SocialSession gains a 5th interface, sync, over the shared connection,
  built with a per-install deviceId + the app's inventory namespace.
- DeviceIdStore: a stable random per-install id in the keystore — distinct
  from the seed-derived CRDT node id, which is IDENTICAL across a user's
  devices (so it couldn't tell them apart / they'd clobber each other's
  replaceable record).
- InventorySnapshotIO (implemented by ExportImportService): build/apply the
  inventory as the SAME interchange JSON as backups but UNSEALED — the sync
  transport does the encryption, so it must not double-seal. Apply = the
  existing idempotent LWW importInventory.
- SyncService: on (re)connect publishes this device's snapshot + subscribes
  to the others'; a local edit (debounced, off tableUpdates) republishes;
  incoming snapshots merge idempotently; skips its own echo; a byte check
  skips re-publishing an unchanged snapshot so the ping-pong converges.
  Foreground only, like the inbox.
- DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity.

Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed +
idempotent), SyncService.handleRemote (applies others', skips own).

Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
This commit is contained in:
vjrj 2026-07-10 23:45:40 +02:00
parent 182c334883
commit 225880fc64
10 changed files with 363 additions and 12 deletions

View file

@ -19,6 +19,7 @@ import '../security/secret_store.dart';
import '../security/secure_key_store.dart';
import '../services/auto_backup_service.dart';
import '../services/auto_backup_store.dart';
import '../services/device_id_store.dart';
import '../services/export_import_service.dart';
import '../services/file_picker_file_service.dart';
import '../services/file_service.dart';
@ -40,6 +41,7 @@ import '../services/social_account_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../services/sync_service.dart';
import '../services/trust_referents.dart';
import '../services/unread_service.dart';
import '../services/wot_settings.dart';
@ -88,6 +90,10 @@ Future<void> configureDependencies() async {
final activeAccount = await accounts.active();
final scope = socialAccountScope(activeAccount);
// Stable per-install id for device-to-device sync (distinct from the
// seed-derived CRDT node id, which is shared across a user's devices).
final deviceId = await DeviceIdStore(secretStore).deviceId();
final database = AppDatabase(
openEncryptedExecutor(await _databaseFile(), dbKeyHex),
);
@ -104,8 +110,11 @@ Future<void> configureDependencies() async {
// profile stay hidden) rather than blanking the whole app.
SocialService? socialService;
try {
socialService =
await SocialService.fromRootSeedHex(rootSeedHex, account: activeAccount);
socialService = await SocialService.fromRootSeedHex(
rootSeedHex,
account: activeAccount,
deviceId: deviceId,
);
} catch (e, s) {
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s');
}
@ -224,6 +233,16 @@ Future<void> configureDependencies() async {
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
),
)
// Replicates the inventory across this identity's own devices over the
// shared connection. Started in `Bootstrap`.
..registerSingleton<SyncService>(
SyncService(
connection: connection,
io: getIt<ExportImportService>(),
localChanges: database.tableUpdates().map<void>((_) {}),
selfDeviceId: deviceId,
),
);
}
@ -244,8 +263,14 @@ Future<void> switchSocialAccount(int account) async {
await getIt<SocialAccountStore>().setActive(account);
final scope = socialAccountScope(account);
final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex();
// Per-install, so it survives an identity switch.
final deviceId = await DeviceIdStore(secretStore).deviceId();
// Tear down the old identity's listener + shared connection before replacing.
// Tear down the old identity's listener + sync + shared connection first.
if (getIt.isRegistered<SyncService>()) {
await getIt<SyncService>().stop();
await getIt.unregister<SyncService>();
}
if (getIt.isRegistered<InboxService>()) {
await getIt<InboxService>().stop();
await getIt.unregister<InboxService>();
@ -275,8 +300,11 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore));
final social =
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
final social = await SocialService.fromRootSeedHex(
rootSeedHex,
account: account,
deviceId: deviceId,
);
final connection =
SocialConnection(social: social, settings: getIt<SocialSettings>());
final inbox = InboxService(
@ -287,12 +315,20 @@ Future<void> switchSocialAccount(int account) async {
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
);
final sync = SyncService(
connection: connection,
io: getIt<ExportImportService>(),
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
selfDeviceId: deviceId,
);
getIt
..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox);
// Subscribe the listener BEFORE the connection starts connecting.
..registerSingleton<InboxService>(inbox)
..registerSingleton<SyncService>(sync);
// Subscribe the listeners BEFORE the connection starts connecting.
inbox.start();
sync.start();
connection.start();
}