tane/apps/app_seeds/lib/di/injector.dart
vjrj 73ee98206f feat(chat): app-wide inbox listener so messages arrive without opening the chat
Reception only ran inside an open ChatScreen for a known peer, so a first
message from a new peer was invisible: nothing in local storage -> nothing
in the inbox list -> you never opened the chat -> you never subscribed.

Add InboxService: one long-lived NIP-17 inbox subscription for the whole
app (foreground), persisting every incoming message to MessageStore and
firing a 'changes' signal the inbox list live-reloads on. Reconnects when
the network returns; degrades to nothing offline. Started from main when a
social identity exists.

Make MessageStore.append idempotent (dedup by sender+timestamp+text) and
serialized behind a write lock — the global listener and an open chat's own
subscription now write the same conversation concurrently and relays
re-deliver stored gift wraps on every resubscribe. Tests for both.

Known trade-offs (follow-ups): foreground-only (no push yet); each of
InboxService/ChatScreen/MarketScreen opens its own RelayPool (a shared
connection is a later optimization).
2026-07-10 16:53:03 +02:00

214 lines
9.1 KiB
Dart

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/species_catalog.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.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/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/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.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/profile_cache.dart';
import '../services/profile_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.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();
final database = AppDatabase(
openEncryptedExecutor(await _databaseFile(), 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);
} 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,
);
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<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService)
..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
..registerSingleton<MessageStore>(MessageStore(secretStore))
..registerSingleton<ProfileStore>(ProfileStore(secretStore))
..registerSingleton<ProfileCache>(ProfileCache(secretStore))
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,
files: fileService,
keys: keyStore,
),
)
..registerSingleton<ShareCatalogService>(
ShareCatalogService(files: fileService),
)
..registerSingleton<RecoverySheetService>(
RecoverySheetService(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,
),
);
}
// Optional: absent only if the derivation above failed — then the app runs
// inventory-only, by design (market/chat/profile hidden).
if (socialService != null) {
getIt
..registerSingleton<SocialService>(socialService)
// App-wide inbox listener so private messages arrive (and land in the
// inbox list) even when the specific chat isn't open. Started in `main`.
..registerSingleton<InboxService>(
InboxService(
social: socialService,
settings: getIt<SocialSettings>(),
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
),
);
}
// 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());
}
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'));
}
/// 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);
}