The global membership rule (curated bootstrap referents + sigQty/stepMax parameters) solved sybil-proof identity for a UBI — a problem this app doesn't have — and its screen leaked graph jargon (npub roots, parameter steppers). Trust is now computed from the user's own position only: you vouch / vouched by people you know (distance <=2) / vouched by N. - TrustCubit: drop networkMember tier, referents and params; keep the circle rule and the 365-day vouch expiry (renewable, self-pruning). - Delete TrustReferents, WotSettings, TrustNetworkScreen and the bundled referents asset; unwire injector/bootstrap/app/chat. - New 'Your people' screen (/your-people, from the profile): who you vouch for (revocable) and who vouches for you, names via ProfileCache. - i18n: wot.* removed, yourPeople.* added (en/es/pt/ast); trust.member removed. Kind 30777 events on relays stay fully compatible — only the interpretation changes.
377 lines
15 KiB
Dart
377 lines
15 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/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/profile_cache.dart';
|
|
import '../services/profile_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,
|
|
);
|
|
|
|
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<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>(),
|
|
),
|
|
)
|
|
// 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,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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<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>();
|
|
|
|
// 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<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>(),
|
|
);
|
|
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)
|
|
..registerSingleton<SyncService>(sync);
|
|
// Subscribe the listeners BEFORE the connection starts connecting.
|
|
inbox.start();
|
|
sync.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);
|
|
}
|