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.
438 lines
17 KiB
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);
|
|
}
|
|
}
|