From 6a611767fc4e8d27d76eb3ad2a895a6da2c96b09 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 2 May 2026 14:48:31 +0200 Subject: [PATCH] fixing startup issues --- CHANGELOG.md | 2 +- android/app/src/main/AndroidManifest.xml | 2 +- lib/app.dart | 14 ++++- lib/globals.dart | 2 + lib/main.dart | 62 ++++++++++++++----- lib/src/providers/purchases.provider.dart | 2 + .../callback_dispatcher.background.dart | 44 ++++++++----- .../notifications/fcm.notifications.dart | 2 + lib/src/utils/log.dart | 3 +- lib/src/utils/startup_guard.dart | 43 +++++++++++++ .../views/settings/help/diagnostics.view.dart | 7 ++- pubspec.yaml | 2 +- rust/src/bridge/log.rs | 25 +++++--- rust/src/bridge/mod.rs | 4 ++ 14 files changed, 166 insertions(+), 48 deletions(-) create mode 100644 lib/src/utils/startup_guard.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb92fbb..6c0239aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.3 +## 0.2.8 - Fix: App did not launch sometimes on Android diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3149d623..2f7732ad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ { bool _showOnboarding = true; bool _isLoaded = false; bool _isTwonlyLocked = true; + bool _wasLogged = true; (Future?, bool) _proofOfWork = (null, false); @override void initState() { - initAsync(); super.initState(); + Log.info('AppWidgetState: initState started'); + initAsync(); } Future initAsync() async { + Log.info('AppWidgetState: initAsync started'); if (userService.isUserCreated) { - await FirebaseMessaging.instance.requestPermission(); + unawaited(FirebaseMessaging.instance.requestPermission()); if (_isTwonlyLocked) { // do not change in case twonly was already unlocked at some point _isTwonlyLocked = userService.currentUser.screenLockEnabled; @@ -158,6 +160,12 @@ class _AppMainWidgetState extends State { @override Widget build(BuildContext context) { + if (!_wasLogged) { + Log.info('AppWidgetState: build started (_isLoaded: $_isLoaded)'); + if (_isLoaded) { + _wasLogged = true; + } + } if (!_isLoaded) { return Center(child: Container()); } diff --git a/lib/globals.dart b/lib/globals.dart index e5d728df..cd9ccc07 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/utils/log.dart'; class AppEnvironment { static late final String cacheDir; @@ -14,6 +15,7 @@ class AppEnvironment { static Future init() async { cacheDir = (await getApplicationCacheDirectory()).path; supportDir = (await getApplicationSupportDirectory()).path; + Log.init(); } static void initTesting() { diff --git a/lib/main.dart b/lib/main.dart index dfde69c2..89789de3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:mutex/mutex.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/app.dart'; @@ -27,33 +28,50 @@ import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/utils/avatars.dart'; +import 'package:twonly/src/utils/exclusive_access.utils.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/secure_storage.dart'; +import 'package:twonly/src/utils/startup_guard.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; +final _initMutex = Mutex(); + /// This function is used to initialized the absolute minimum so it /// can also be used by the backend without the UI was loaded. Future twonlyMinimumInitialization() async { - SentryWidgetsFlutterBinding.ensureInitialized(); + Log.info('twonlyMinimumInitialization: called'); + await exclusiveAccess( + lockName: 'init', + mutex: _initMutex, + action: () async { + Log.info('twonlyMinimumInitialization: started'); + setupLocator(); - await AppEnvironment.init(); - Log.init(); - setupLocator(); + Log.info('twonlyMinimumInitialization: RustLib.init()'); + await RustLib.init(); - await RustLib.init(); + Log.info('twonlyMinimumInitialization: initFlutterCallbacksForRust()'); + await initFlutterCallbacksForRust(); - await initFlutterCallbacksForRust(); - - await bridge.initializeTwonlyFlutter( - config: bridge.TwonlyConfig( - databasePath: '${AppEnvironment.supportDir}/twonly.sqlite', - dataDirectory: AppEnvironment.supportDir, - ), + Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()'); + await bridge.initializeTwonlyFlutter( + config: bridge.TwonlyConfig( + databasePath: '${AppEnvironment.supportDir}/twonly.sqlite', + dataDirectory: AppEnvironment.supportDir, + ), + ); + Log.info('twonlyMinimumInitialization: finished'); + }, ); } void main() async { + final binding = SentryWidgetsFlutterBinding.ensureInitialized(); + await AppEnvironment.init(); final stopwatch = Stopwatch()..start(); + + unawaited(StartupGuard.markAppStartup()); + await twonlyMinimumInitialization(); unawaited(initFCMService()); @@ -77,6 +95,8 @@ void main() async { } } + Log.info('User loaded.'); + final settingsController = SettingsChangeProvider()..loadSettings(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); unawaited(initFileDownloader()); @@ -94,15 +114,22 @@ void main() async { } await runMigrations(); - unawaited(postStartupTasks()); + // We wait for the first frame to be rendered before starting heavy tasks. + // This ensures the splash screen is dismissed on Android immediately. + binding.addPostFrameCallback((_) async { + await Future.delayed(const Duration(seconds: 1)); + unawaited(postStartupTasks()); + unawaited(apiService.connect()); + }); } await apiService.listenToNetworkChanges(); - unawaited(apiService.connect()); stopwatch.stop(); - Log.info('Initialization finished after ${stopwatch.elapsed}.'); + Log.info( + 'Initialization finished after ${stopwatch.elapsed}. Calling runApp...', + ); runApp( MultiProvider( @@ -154,6 +181,7 @@ Future runMigrations() async { } Future postStartupTasks() async { + Log.info('Post startup started.'); // 1. Immediate background cleanup (Non-blocking for UI) await twonlyDB.messagesDao.purgeMessageTable(); unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts()); @@ -164,9 +192,11 @@ Future postStartupTasks() async { unawaited(setupPushNotification()); unawaited(finishStartedPreprocessing()); unawaited(createPushAvatars()); - unawaited(initializeBackgroundTaskManager()); + await Future.delayed(const Duration(seconds: 10)); + unawaited(initializeBackgroundTaskManager()); // 3. Delayed tasks (Wait for app to settle) await Future.delayed(const Duration(minutes: 2)); unawaited(performTwonlySafeBackup()); + unawaited(cleanLogFile()); } diff --git a/lib/src/providers/purchases.provider.dart b/lib/src/providers/purchases.provider.dart index d3c58f45..5a09a01d 100644 --- a/lib/src/providers/purchases.provider.dart +++ b/lib/src/providers/purchases.provider.dart @@ -54,6 +54,7 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { } loadPurchases(); + Log.info('PurchasesProvider: constructor finished'); } SubscriptionPlan plan = SubscriptionPlan.Free; @@ -74,6 +75,7 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { Future loadPurchases() async { final available = await iapConnection.isAvailable(); + Log.info('PurchasesProvider: IAP available: $available'); if (!available) { storeState = StoreState.notAvailable; Log.warn('Store is not available'); diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart index 7d2260a4..4dcc68f2 100644 --- a/lib/src/services/background/callback_dispatcher.background.dart +++ b/lib/src/services/background/callback_dispatcher.background.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:mutex/mutex.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/main.dart'; @@ -9,6 +10,7 @@ import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/utils/exclusive_access.utils.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/startup_guard.dart'; import 'package:workmanager/workmanager.dart'; // ignore: unreachable_from_main @@ -16,27 +18,27 @@ Future initializeBackgroundTaskManager() async { await Workmanager().initialize(callbackDispatcher); await Workmanager().cancelByUniqueName('fetch_data_from_server'); - await Workmanager().registerPeriodicTask( - 'fetch_data_from_server', - 'eu.twonly.periodic_task', - frequency: const Duration(minutes: 20), - initialDelay: const Duration(minutes: 5), - existingWorkPolicy: ExistingPeriodicWorkPolicy.update, - constraints: Constraints( - networkType: NetworkType.connected, - ), - ); + // await Workmanager().registerPeriodicTask( + // 'fetch_data_from_server', + // 'eu.twonly.periodic_task', + // frequency: const Duration(minutes: 20), + // initialDelay: const Duration(minutes: 5), + // existingWorkPolicy: ExistingPeriodicWorkPolicy.update, + // constraints: Constraints( + // networkType: NetworkType.connected, + // ), + // ); } @pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { - AppState.isInBackgroundTask = true; + SentryWidgetsFlutterBinding.ensureInitialized(); switch (task) { case 'eu.twonly.periodic_task': - if (await initBackgroundExecution()) { - await handlePeriodicTask(); - } + // if (await initBackgroundExecution()) { + // await handlePeriodicTask(); + // } case 'eu.twonly.processing_task': if (await initBackgroundExecution()) { await handleProcessingTask(); @@ -51,6 +53,18 @@ void callbackDispatcher() { bool _isInitialized = false; Future initBackgroundExecution() async { + // 1. Check startup guard IMMEDIATELY before doing ANYTHING else. + if (await StartupGuard.isAppStarting()) { + return false; + } + + await AppEnvironment.init(); + AppState.isInBackgroundTask = true; + + if (await StartupGuard.isAppStarting()) { + Log.error('App is starting. Returning early.'); + return false; + } if (_isInitialized) { // Reload the users, as on Android the background isolate can // stay alive for multiple hours between task executions @@ -64,6 +78,8 @@ Future initBackgroundExecution() async { return false; } + Log.info('Background task is initialized'); + _isInitialized = true; return true; } diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index c8bdd4bf..8c5a7009 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -7,6 +7,7 @@ import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart'; @@ -117,6 +118,7 @@ Future initFCMService() async { @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + SentryWidgetsFlutterBinding.ensureInitialized(); final isInitialized = await initBackgroundExecution(); Log.info('Handling a background message: ${message.messageId}'); await handleRemoteMessage(message); diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index 0bbcfcac..22f3ec94 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -24,7 +24,6 @@ class Log { ); } }); - cleanLogFile(); } static String filterLogMessage(String msg) { @@ -106,7 +105,7 @@ Future _writeLogToFile(LogRecord record) async { final logFile = File('${AppEnvironment.supportDir}/app.log'); final logMessage = - '${clock.now().toString().split(".")[0]} ${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}\n'; + '${clock.now()} ${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}\n'; return _protectFileAccess(() async { if (!logFile.existsSync()) { diff --git a/lib/src/utils/startup_guard.dart b/lib/src/utils/startup_guard.dart new file mode 100644 index 00000000..958b6565 --- /dev/null +++ b/lib/src/utils/startup_guard.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/utils/log.dart'; + +class StartupGuard { + static Future _getLockFile() async { + final path = (await getApplicationCacheDirectory()).path; + return File('$path/app_startup.lock'); + } + + static Future markAppStartup() async { + try { + final file = await _getLockFile(); + await file.writeAsString( + DateTime.now().millisecondsSinceEpoch.toString(), + ); + Log.info('App is starting'); + } catch (e) { + Log.error('Failed to mark app startup: $e'); + } + } + + static Future isAppStarting() async { + try { + final file = await _getLockFile(); + if (!file.existsSync()) return false; + + final stat = file.statSync(); + final diff = DateTime.now().difference(stat.modified); + + final starting = diff.inSeconds < 30; + if (starting) { + Log.info( + 'Startup guard: App is currently starting (${diff.inSeconds}s ago).', + ); + } + return starting; + } catch (e) { + Log.error('Failed to check startup guard: $e'); + return false; + } + } +} diff --git a/lib/src/visual/views/settings/help/diagnostics.view.dart b/lib/src/visual/views/settings/help/diagnostics.view.dart index 0af711d7..f664368f 100644 --- a/lib/src/visual/views/settings/help/diagnostics.view.dart +++ b/lib/src/visual/views/settings/help/diagnostics.view.dart @@ -176,7 +176,7 @@ class _LogViewerWidgetState extends State { children: [ if (_showTimestamps && e.timestamp != null) TextSpan( - text: '${e.timestamp.toString().split(' ')[1].split('.')[0]} ', + text: '${e.timestamp.toString().split(' ')[1]} ', style: tsStyle, ), WidgetSpan( @@ -306,8 +306,9 @@ class _LogEntry { String? level; var msg = trimmed; - // Try to parse leading timestamp (YYYY-MM-DD HH:MM:SS) - final tsRegex = RegExp(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(.*)$'); + // Try to parse leading timestamp (YYYY-MM-DD HH:MM:SS.mmmmmm) + final tsRegex = + RegExp(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(.*)$'); final mTs = tsRegex.firstMatch(trimmed); if (mTs != null) { try { diff --git a/pubspec.yaml b/pubspec.yaml index 80297902..d43bc94a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.3+112 +version: 0.2.8+117 environment: sdk: ^3.11.0 diff --git a/rust/src/bridge/log.rs b/rust/src/bridge/log.rs index db6a0935..fb41ffd6 100644 --- a/rust/src/bridge/log.rs +++ b/rust/src/bridge/log.rs @@ -55,19 +55,30 @@ pub(crate) async fn init_tracing(logs_dir: &std::path::Path, is_dart_available: } fn build_writers(logs_dir: &std::path::Path) -> (NonBlocking, NonBlocking) { - let file_appender = tracing_appender::rolling::RollingFileAppender::builder() + let file_appender_res = tracing_appender::rolling::RollingFileAppender::builder() .rotation(tracing_appender::rolling::Rotation::DAILY) .filename_prefix("twonly") .filename_suffix("log") - .build(logs_dir) - .expect("Failed to create file appender"); + .build(logs_dir); - let (non_blocking_file, file_guard) = tracing_appender::non_blocking(file_appender); + let (non_blocking_file, file_guard) = match file_appender_res { + Ok(file_appender) => { + let (nb, guard) = tracing_appender::non_blocking(file_appender); + (nb, Some(guard)) + } + Err(e) => { + eprintln!("Failed to create file appender: {}", e); + let (nb, guard) = tracing_appender::non_blocking(std::io::sink()); + (nb, None) + } + }; let (non_blocking_stdout, stdout_guard) = tracing_appender::non_blocking(std::io::stdout()); - TRACING_GUARDS - .set(Mutex::new(Some((file_guard, stdout_guard)))) - .ok(); + if let Some(fg) = file_guard { + TRACING_GUARDS + .set(Mutex::new(Some((fg, stdout_guard)))) + .ok(); + } (non_blocking_stdout, non_blocking_file) } diff --git a/rust/src/bridge/mod.rs b/rust/src/bridge/mod.rs index 5b5c6422..dd4a7aa0 100644 --- a/rust/src/bridge/mod.rs +++ b/rust/src/bridge/mod.rs @@ -57,6 +57,10 @@ pub(super) fn get_twonly_flutter() -> Result<&'static TwonlyFlutter> { } pub async fn initialize_twonly_flutter(config: TwonlyConfig) -> Result<()> { + if GLOBAL_TWONLY.initialized() { + tracing::info!("twonly already initialized."); + return Ok(()); + } let log_dir = PathBuf::from(&config.data_directory).join("log"); init_tracing(&log_dir, true).await; tracing::info!("Initialized twonly workspace.");