You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/main.dart

438 lines
17 KiB

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
import 'services/mesh_topology_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
import 'services/app_debug_log_service.dart';
import 'services/app_exit_info_service.dart';
import 'services/file_log_service.dart';
import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart';
import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart';
import 'services/block_service.dart';
import 'services/window_geometry_service.dart';
import 'services/store_consolidation_service.dart';
import 'services/stale_history_sweep.dart';
import 'services/storage_health_service.dart';
import 'services/radio_preset_service.dart';
import 'storage/drift/blob_store.dart';
import 'widgets/contact_sync_shortfall_banner.dart';
import 'widgets/factory_reset_banner.dart';
import 'widgets/storage_unavailable_banner.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
import 'widgets/keep_screen_awake.dart';
import 'widgets/sponsor_prompt.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache
await PrefsManager.initialize();
// Probe the storage layer up front (#385). If the database can't open, e.g.
// the native sqlite library fails to load, every read/write silently fails
// and the app looks wiped. Capture that here so the UI can warn loudly
// instead of showing an empty, normal-looking screen (SAFELANE §6).
final storageHealth = StorageHealthService();
try {
if (!await BlobStore.instance.verifyReadWrite()) {
storageHealth.markUnavailable('database read-back mismatch');
}
} catch (e) {
storageHealth.markUnavailable(e);
// appLogger is not initialized this early; debugPrint is safe here and the
// per-store operations still log via appLogger once it is up.
debugPrint('[Storage] Health probe failed; storage unavailable: $e');
}
// Move bulk data (message history, contacts) out of SharedPreferences into
// drift (#335). Must run after prefs are up and BEFORE any store reads, so
// no code sees a half-migrated state. Idempotent: a no-op once done.
//
// Deliberately awaited: the alternative is stores racing the migration, and
// this is the failure mode that produced #333.
await BlobStore.instance.migrateFromPrefs();
// Consolidate message stores left in other locations by differently-built
// copies (#367): union their messages into the pinned store so nothing shows
// as a gap. One-time, native-only, and before any store is read into memory.
await StoreConsolidationService.run();
// Start always-on file logging (#97); no-op on web.
await FileLogService.instance.init();
// Restore the desktop window's saved position/size (#349); no-op off desktop.
await WindowGeometryService.instance.initialize();
// Initialize services
final storage = StorageService();
final connector = MeshCoreConnector();
// StorageService is shared with PathHistoryService and
// TimeoutPredictionService, so the connector cannot own it the way it owns
// the other per-radio stores. It still needs the device key on connect,
// because path history and delivery observations are per-radio now
// (#854, #856).
connector.storageService = storage;
final pathHistoryService = PathHistoryService(storage);
final meshTopologyService = MeshTopologyService();
final retryService = MessageRetryService();
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
final appDebugLogService = AppDebugLogService();
final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
final translationService = TranslationService(appSettingsService);
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
final blockService = BlockService();
// Load settings
await appSettingsService.loadSettings();
// Initialize app logger
appLogger.initialize(
appDebugLogService,
enabled: appSettingsService.settings.appDebugLogEnabled,
);
try {
final packageInfo = await PackageInfo.fromPlatform();
appLogger.appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
} catch (e) {
appLogger.error('Could not read the app version: $e', tag: 'App');
}
appLogger.info('App ${appLogger.appVersion} started', tag: 'App');
appSettingsService.logLoadReport();
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
await backgroundService.initialize();
backgroundService.setLanguageOverrideProvider(
() => appSettingsService.settings.languageOverride,
);
_registerThirdPartyLicenses();
await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
await blockService.load();
// Regional radio presets (#728): the last good copy, or the one bundled with
// the app. The picker refreshes it from config-profiles when opened.
final radioPresetService = RadioPresetService();
await radioPresetService.load();
// Wire up connector with services
connector.initialize(
retryService: retryService,
pathHistoryService: pathHistoryService,
topologyService: meshTopologyService,
appSettingsService: appSettingsService,
translationService: translationService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
);
await connector.loadContactCache();
await connector.loadChannelSettings();
await connector.loadCachedChannels();
// Load persisted channel messages
await connector.loadAllChannelMessages();
await connector.loadUnreadState();
runApp(
MeshCoreApp(
storageHealth: storageHealth,
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
meshTopologyService: meshTopologyService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService,
translationService: translationService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
radioPresetService: radioPresetService,
),
);
// Report why the previous process died, if it did (#906). Scheduled after the
// first frame and deliberately not awaited: #334 is the leading hypothesis for
// the ANRs this exists to diagnose, so the collector must not itself add work
// to startup. The Android side does its query and trace reads on a background
// executor.
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(AppExitInfoService.instance.collect());
// Reclaim the stale pre-migration history blobs (#962), once per launch.
//
// Same shape and the same reasoning as the collector above: after the
// first frame and not awaited, because storage hygiene must never be on
// the path to a usable screen. It is a no-op on almost every launch, since
// a key is swept once and then has no blob left to consider, so the
// VACUUM inside it is rare rather than per-launch.
//
// Caught HERE rather than inside the sweep. Nothing about reclaiming disk
// space is worth a broken start, but the manual action (#966) needs a
// failure it can report to the user, so `run` stays able to throw and this
// fire-and-forget call site is what absorbs it.
unawaited(
StaleHistorySweep(BlobStore.instance).run().catchError((
Object e,
StackTrace s,
) {
appLogger.error(
'The startup stale-history sweep failed and was skipped. No blob is '
'deleted on this path unless it was verified first, so the history '
'is intact: $e\n$s',
tag: 'Storage',
);
return const StaleHistoryOutcome(
cleared: [],
refused: [],
bytesBefore: 0,
bytesAfter: 0,
);
}),
);
});
}
void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
<String>['Open-Meteo Elevation API Data'],
'''
Data used by LOS elevation lookups is provided by Open-Meteo.
Open-Meteo terms and attribution:
https://open-meteo.com/en/terms
Elevation API:
https://open-meteo.com/en/docs/elevation-api
Attribution license reference:
Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
''',
);
yield const LicenseEntryWithLineBreaks(
<String>['MeshCore suggested radio presets'],
'''
Regional radio presets are MeshCore's suggested settings, maintained by Liam
Cottle for MeshCore and published at https://api.meshcore.nz/api/v1/config.
Offband mirrors them unmodified in OffbandMesh/config-profiles and adds its
own regional presets alongside.
MeshCore: https://github.com/meshcore-dev/MeshCore
Mirror and credit: https://github.com/OffbandMesh/config-profiles#credit
''',
);
});
}
class MeshCoreApp extends StatelessWidget {
final StorageHealthService storageHealth;
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
final MeshTopologyService meshTopologyService;
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService;
final TranslationService translationService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
final BlockService blockService;
final RadioPresetService radioPresetService;
const MeshCoreApp({
super.key,
required this.storageHealth,
required this.connector,
required this.retryService,
required this.pathHistoryService,
required this.meshTopologyService,
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
required this.appDebugLogService,
required this.mapTileCacheService,
required this.chatTextScaleService,
required this.translationService,
required this.uiViewStateService,
required this.timeoutPredictionService,
required this.blockService,
required this.radioPresetService,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: storageHealth),
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: meshTopologyService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: translationService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider.value(value: blockService),
ChangeNotifierProvider.value(value: radioPresetService),
ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'Offband MeshCore',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: KeepScreenAwake(
child: Consumer<StorageHealthService>(
builder: (context, health, _) => StorageUnavailableBanner(
show: !health.available,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, _) =>
ContactSyncShortfallBanner(
shortfall: connector.contactSyncShortfall,
decisionPending:
connector.contactSyncDecisionPending,
undeliveredCount:
connector.contactSyncUndeliveredCount,
onDismiss: connector.dismissContactSyncShortfall,
onKeep: connector.resolveContactSyncKeepLocal,
onUseRadio: connector.resolveContactSyncUseRadio,
child: FactoryResetBanner(
status: connector.factoryResetStatus,
onDismiss: connector.dismissFactoryResetStatus,
child: child ?? const SizedBox.shrink(),
),
),
),
),
),
),
);
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const SponsorPromptGate(child: ScannerScreen()),
);
},
),
);
}
ThemeMode _themeModeFromSetting(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final iconBrightness = isDark ? Brightness.light : Brightness.dark;
// Keep Android system bars aligned with the resolved Flutter theme.
return SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: iconBrightness,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: colorScheme.surface,
systemNavigationBarIconBrightness: iconBrightness,
systemNavigationBarDividerColor: colorScheme.surface,
systemNavigationBarContrastEnforced: false,
);
}
Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null;
return Locale(languageCode);
}
}

Powered by TurnKey Linux.