tane/apps/app_seeds/lib/di/injector.dart
vjrj 2e25e34d11 feat(plantare): PlantareService — orchestrate the signed handshake
The app-layer driver that turns local intent into a signed proposal,
counter-signs or declines incoming ones, and reconciles every move into
the local ledger. Cryptography and wire format stay in commons_core;
this owns persistence and orchestration.

- propose(): builds + self-signs a PlantarePledge, records the local row
  (remoteState=proposed, my stub) and sends it. Direction maps to
  debtor/creditor: iReturn = I received and owe, owedToMe = I gave.
- accept(): counter-signs the exact in-hand proposal (verified) and sends
  the doubly-signed copy back, closing the row.
- decline(): notifies the proposer and marks the row declined.
- ingest(): verifies stubs before storing — a bad-signature proposal or a
  half-signed "accept" is dropped; proposals not addressed to me are
  ignored. Pending proposals held in memory keyed by pledge id (relay
  redelivers on reconnect), so accept signs the verified payload.

Wired into DI + Bootstrap alongside the inbox/sync listeners and torn
down/rebuilt on identity switch. SocialSession now exposes the transport.

Tests: full propose→accept close, decline, and the three drop paths with
a records-only transport + real repos. services/data green.
2026-07-14 11:21:46 +02:00

417 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../data/seed_saving_catalog.dart';
import '../data/species_catalog.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/chat_database.dart';
import '../db/database.dart';
import '../db/encrypted_executor.dart';
import '../i18n/strings.g.dart';
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';
import '../services/inbox_service.dart';
import '../services/locale_store.dart';
import '../services/notification_service.dart';
import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
import '../services/label_sheet_service.dart';
import '../services/onboarding_store.dart';
import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart';
import '../services/message_store.dart';
import '../services/offer_outbox.dart';
import '../services/plantare_service.dart';
import '../services/profile_cache.dart';
import '../services/profile_store.dart';
import '../services/saved_offers_store.dart';
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/unread_service.dart';
/// The app's service locator. Kept to the composition root — widgets get their
/// repositories from here (or via BlocProvider), never by reaching into it deep
/// in the tree.
final GetIt getIt = GetIt.instance;
/// End-of-init sentinel: registered last, so `isRegistered<_DepsReady>()` means
/// the container is fully wired (see [configureDependencies]). Distinct from
/// [SocialService], which is optional and absent in inventory-only mode.
class _DepsReady {
const _DepsReady();
}
/// Wires the encrypted DB, keystore and repositories. Call once from `main`
/// before `runApp`; the DB key must exist before the DB opens.
///
/// Idempotent AND all-or-nothing: a hot restart (or an Android Activity restart
/// that keeps the isolate) re-runs `main` while GetIt still holds the singletons.
///
/// A dedicated [_DepsReady] marker is registered LAST and used as the "fully
/// wired" sentinel, so `isRegistered<_DepsReady>()` means the WHOLE container is
/// ready — never a half-built one. If a prior run crashed part-way through init
/// (leaving some singletons registered but not the marker), we reset the
/// container and rebuild from scratch rather than early-returning on a partial
/// container or throwing "already registered" mid-cascade. That partial state is
/// safe to reset because `main` aborts before `runApp` on a partial run, so no
/// live UI holds these singletons. Without this, the app intermittently fell
/// back to the "inventory only" look (market/chat/profile greyed out).
Future<void> configureDependencies() async {
if (getIt.isRegistered<_DepsReady>()) return; // fully wired already
// A prior run half-wired the container (crashed after some registrations but
// before the sentinel). Clear it so we rebuild cleanly.
if (getIt.isRegistered<AppDatabase>()) await getIt.reset();
final secretStore = FlutterSecretStore();
final keyStore = SecureKeyStore(store: secretStore);
final dbKeyHex = await keyStore.databaseKeyHex();
final rootSeedHex = await keyStore.rootSeedHex();
// Which social identity ("account") of the root seed is active. Namespaces the
// per-identity stores (chats, profile, name cache) so identities never mix.
final accounts = SocialAccountStore(secretStore);
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),
);
// Chat history lives in its own encrypted database (same SQLCipher key),
// isolated from the inventory schema/sync. See [Messages].
final chatDatabase = ChatDatabase(
openEncryptedExecutor(await _chatDatabaseFile(), dbKeyHex),
);
// CRDT author id: a stable per-install slice of the root seed. Kept distinct
// from the social-layer public key on purpose — unifying the two would rewrite
// authorship of existing rows, a data-model decision for later.
final nodeId = rootSeedHex.substring(0, 16);
// Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the
// SAME root seed, so it needs no extra backup. Cheap + offline; no relay is
// contacted here (local-first — the social layer only enriches). Non-fatal: if
// the derivation ever fails, the app still opens on inventory (market/chat/
// profile stay hidden) rather than blanking the whole app.
SocialService? socialService;
try {
socialService = await SocialService.fromRootSeedHex(
rootSeedHex,
account: activeAccount,
deviceId: deviceId,
);
} catch (e, s) {
debugPrint(
'Social identity derivation failed; inventory-only mode: $e\n$s',
);
}
// Seed the bundled species catalog before the UI opens — but only once per
// catalog version. Parsing the ~1.5 MB catalog and scanning the species table
// on every launch was the main startup jank; the version marker skips both
// when this install already holds the current catalog.
const catalogVersionKey = 'species_catalog_version';
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
await speciesRepository.seedBundledIfNeeded(
version: speciesCatalogVersion,
readVersion: () => secretStore.read(catalogVersionKey),
writeVersion: (v) => secretStore.write(catalogVersionKey, v),
loadSeeds: loadBundledSpecies,
);
// Small read-only reference data — kept in memory, not the DB. Loaded before
// the UI so the variety detail can show seed-saving guidance synchronously.
await SeedSavingCatalog.ensureLoaded();
final varietyRepository = VarietyRepository(
database,
idGen: IdGen(),
nodeId: nodeId,
);
const fileService = FilePickerFileService();
// OCR label suggestions only where a native Tesseract engine exists; every
// other platform (desktop, web) degrades to the no-op and hides the button.
// The language pack(s) follow the user's locale(s) — never a fixed region —
// limited to what we actually bundle, always unioned with the Latin baseline.
final LabelTextExtractor labelExtractor =
(Platform.isAndroid || Platform.isIOS)
? TesseractLabelExtractor(language: await _resolveOcrLanguage())
: const NoOpLabelTextExtractor();
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<ChatDatabase>(chatDatabase)
..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService)
..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<SocialAccountStore>(accounts)
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
// Per-identity stores are namespaced by the active account's scope.
..registerSingleton<MessageStore>(
MessageStore(chatDatabase, accountScope: scope),
)
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,
files: fileService,
keys: keyStore,
),
)
..registerSingleton<ShareCatalogService>(
ShareCatalogService(files: fileService),
)
..registerSingleton<RecoverySheetService>(
RecoverySheetService(files: fileService),
)
..registerSingleton<LabelSheetService>(
LabelSheetService(files: fileService),
);
// Automatic silent backups need real file storage; the web build has none, so
// it simply goes without (the manual "save a copy" still works there).
if (!kIsWeb) {
getIt.registerSingleton<AutoBackupService>(
AutoBackupService(
exporter: getIt<ExportImportService>(),
store: AutoBackupStore(secretStore),
directory: _backupsDir,
),
);
}
// OS notifications for foreground messages. Registered unconditionally — it
// self-no-ops on unsupported platforms (web/Windows) and when the social layer
// is absent nothing ever calls it. Its tap handler is wired to the router in
// `TaneApp`, after the router exists.
getIt.registerSingleton<NotificationService>(NotificationService());
// Optional: absent only if the derivation above failed — then the app runs
// inventory-only, by design (market/chat/profile hidden).
if (socialService != null) {
// ONE shared relay connection per identity, reused by every feature.
final connection = SocialConnection(
social: socialService,
settings: getIt<SocialSettings>(),
);
getIt
..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialConnection>(connection)
// Tracks unread private messages so the UI can badge them.
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore),
)
// App-wide inbox listener over the shared connection, so private messages
// arrive (and land in the inbox list) even when the chat isn't open.
// Started in `Bootstrap`; it also updates unread and fires notifications.
..registerSingleton<InboxService>(
InboxService(
connection: connection,
selfPubkey: socialService.publicKeyHex,
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
settings: getIt<SocialSettings>(),
),
)
// 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,
),
)
// Drives the bilateral signed Plantaré handshake over the shared
// connection, reconciling moves into the local ledger. Started in
// `Bootstrap`.
..registerSingleton<PlantareService>(
PlantareService(
repo: varietyRepository,
selfPubkey: socialService.publicKeyHex,
selfSecretKey: socialService.identity.privateKeyHex,
connection: connection,
),
);
}
// Registered LAST: the "fully wired" sentinel the guard above checks. Anything
// that throws before here leaves the marker unregistered, so the next run
// rebuilds cleanly instead of reusing a half-built container.
getIt.registerSingleton<_DepsReady>(const _DepsReady());
}
/// Switches the active social identity to [account], re-deriving the Nostr key
/// from the SAME root seed and re-scoping the per-identity stores (chats,
/// profile, name cache) so nothing leaks between identities. Restarts the inbox
/// listener on the new identity. The caller must rebuild the widget tree
/// (`RestartWidget.restart`) so screens and router pick up the new singletons.
/// The DB, inventory and relay settings are untouched — only the social slice.
Future<void> switchSocialAccount(int account) async {
final secretStore = FlutterSecretStore();
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 + 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>();
}
if (getIt.isRegistered<PlantareService>()) {
await getIt<PlantareService>().stop();
await getIt.unregister<PlantareService>();
}
if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>();
}
if (getIt.isRegistered<UnreadService>()) {
await getIt.unregister<UnreadService>();
}
if (getIt.isRegistered<SocialService>()) {
await getIt.unregister<SocialService>();
}
await getIt.unregister<MessageStore>();
await getIt.unregister<ProfileStore>();
await getIt.unregister<ProfileCache>();
await getIt<SavedOffersStore>().close();
await getIt.unregister<SavedOffersStore>();
// Re-register the per-identity stores under the new scope, then the identity.
getIt
..registerSingleton<MessageStore>(
MessageStore(getIt<ChatDatabase>(), accountScope: scope),
)
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore),
);
final social = await SocialService.fromRootSeedHex(
rootSeedHex,
account: account,
deviceId: deviceId,
);
final connection = SocialConnection(
social: social,
settings: getIt<SocialSettings>(),
);
final inbox = InboxService(
connection: connection,
selfPubkey: social.publicKeyHex,
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
settings: getIt<SocialSettings>(),
);
final sync = SyncService(
connection: connection,
io: getIt<ExportImportService>(),
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
selfDeviceId: deviceId,
);
final plantares = PlantareService(
repo: getIt<VarietyRepository>(),
selfPubkey: social.publicKeyHex,
selfSecretKey: social.identity.privateKeyHex,
connection: connection,
);
getIt
..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox)
..registerSingleton<SyncService>(sync)
..registerSingleton<PlantareService>(plantares);
// Subscribe the listeners BEFORE the connection starts connecting.
inbox.start();
sync.start();
plantares.start();
connection.start();
}
Future<Directory> _backupsDir() async {
final dir = await getApplicationSupportDirectory();
return Directory(p.join(dir.path, 'backups'));
}
Future<File> _databaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'tane_inventory.sqlite'));
}
Future<File> _chatDatabaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'tane_chat.sqlite'));
}
/// Picks the Tesseract language pack(s) for the current locale(s), limited to
/// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen
/// language, then the device's ordered locales.
Future<String> _resolveOcrLanguage() async {
final available = parseAvailableOcrLanguages(
await rootBundle.loadString('assets/tessdata_config.json'),
);
final preferred = <String>[
LocaleSettings.currentLocale.languageCode,
for (final l in WidgetsBinding.instance.platformDispatcher.locales)
l.languageCode,
];
return resolveOcrLanguages(preferredLocales: preferred, available: available);
}