diff --git a/CHANGELOG.md b/CHANGELOG.md index f53e8a8c..6c0239aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.8 + +- Fix: App did not launch sometimes on Android + ## 0.2.0 - New: Feature to find friends without a phone number diff --git a/analysis_options.yaml b/analysis_options.yaml index ecfa2298..229bcedc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,6 +18,7 @@ analyzer: - "lib/generated/**" - "lib/core/**" - "lib/src/localization/**" + - "rust_builder/" - "dependencies/**" - "pubspec.yaml" - "**.arb" 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 86ac2d78..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,32 +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()); @@ -67,21 +86,20 @@ void main() async { storageError = true; } - final dbExists = File( - '${AppEnvironment.supportDir}/twonly.sqlite', - ).existsSync(); - if (Platform.isIOS && userExists) { - if (!dbExists) { + final dbFile = File('${AppEnvironment.supportDir}/twonly.sqlite'); + if (!dbFile.existsSync()) { Log.error('[twonly] IOS: App was removed and then reinstalled again...'); await SecureStorage.instance.deleteAll(); userExists = false; } } + Log.info('User loaded.'); + final settingsController = SettingsChangeProvider()..loadSettings(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - await initFileDownloader(); + unawaited(initFileDownloader()); if (userExists) { if (userService.currentUser.allowErrorTrackingViaSentry) { @@ -96,22 +114,22 @@ void main() async { } await runMigrations(); - - await twonlyDB.messagesDao.purgeMessageTable(); - await twonlyDB.receiptsDao.purgeReceivedReceipts(); - await UserDiscoveryService.removeDeletedContacts(); - - unawaited(MediaFileService.purgeTempFolder()); - - unawaited(setupPushNotification()); - unawaited(finishStartedPreprocessing()); - unawaited(createPushAvatars()); - unawaited(performTwonlySafeBackup()); - unawaited(initializeBackgroundTaskManager()); + // 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}. Calling runApp...', + ); runApp( MultiProvider( @@ -161,3 +179,24 @@ 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()); + unawaited(UserDiscoveryService.removeDeletedContacts()); + unawaited(MediaFileService.purgeTempFolder()); + + // 2. Service initializations + unawaited(setupPushNotification()); + unawaited(finishStartedPreprocessing()); + unawaited(createPushAvatars()); + + 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/callbacks/logging.callbacks.dart b/lib/src/callbacks/logging.callbacks.dart index dff9b1ad..bba3d194 100644 --- a/lib/src/callbacks/logging.callbacks.dart +++ b/lib/src/callbacks/logging.callbacks.dart @@ -19,7 +19,6 @@ class LoggingCallbacks { print(log); } }, - onDone: () => Log.info('Log stream closed'), ); timer.cancel(); } catch (e) { diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 5ff7cb67..3afade5f 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -65,6 +65,11 @@ class MediaFilesDao extends DatabaseAccessor )..where((t) => t.mediaId.equals(mediaId))).getSingleOrNull(); } + Future> getMediaFilesByIds(List mediaIds) async { + return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get(); + } + + Future getDraftMediaFile() async { final medias = await (select( mediaFiles, diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 29edbc03..ace070ea 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -140,15 +140,22 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { Future purgeMessageTable() async { final allGroups = await select(groups).get(); - for (final group in allGroups) { + final groupedByTime = >{}; + for (final g in allGroups) { + groupedByTime + .putIfAbsent(g.deleteMessagesAfterMilliseconds, () => []) + .add(g.groupId); + } + + for (final entry in groupedByTime.entries) { final deletionTime = clock.now().subtract( - Duration( - milliseconds: group.deleteMessagesAfterMilliseconds, - ), + Duration(milliseconds: entry.key), ); + final groupIds = entry.value; + await (delete(messages)..where( (m) => - m.groupId.equals(group.groupId) & + m.groupId.isIn(groupIds) & (m.mediaStored.equals(true) & m.isDeletedFromSender.equals(true) | m.mediaStored.equals(false)) & @@ -404,6 +411,10 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { return (select(messages)..where((t) => t.mediaId.equals(mediaId))).get(); } + Future> getMessagesByMediaIds(List mediaIds) async { + return (select(messages)..where((t) => t.mediaId.isIn(mediaIds))).get(); + } + Stream> watchMessageActions(String messageId) { final query = (select(messageActions).join([ leftOuterJoin( 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/api/mediafiles/upload.api.dart b/lib/src/services/api/mediafiles/upload.api.dart index 7689365b..e84a77dc 100644 --- a/lib/src/services/api/mediafiles/upload.api.dart +++ b/lib/src/services/api/mediafiles/upload.api.dart @@ -23,6 +23,7 @@ import 'package:twonly/src/services/api/mediafiles/media_background.api.dart'; import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/exclusive_access.utils.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/secure_storage.dart'; @@ -31,124 +32,128 @@ import 'package:workmanager/workmanager.dart' hide TaskStatus; final lockRetransmission = Mutex(); Future reuploadMediaFiles() async { - return lockRetransmission.protect(() async { - final receipts = await twonlyDB.receiptsDao - .getReceiptsForMediaRetransmissions(); + return exclusiveAccess( + lockName: 'reupload_maintenance', + mutex: lockRetransmission, + action: () async { + final receipts = await twonlyDB.receiptsDao + .getReceiptsForMediaRetransmissions(); - if (receipts.isEmpty) return; + if (receipts.isEmpty) return; - Log.info('Reuploading ${receipts.length} media files to the server.'); + Log.info('Reuploading ${receipts.length} media files to the server.'); - final contacts = {}; + final contacts = {}; - for (final receipt in receipts) { - if (receipt.retryCount > 1 && receipt.lastRetry != null) { - final twentyFourHoursAgo = DateTime.now().subtract( - const Duration(hours: 24), - ); - if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) { - Log.info( - 'Ignoring ${receipt.receiptId} as it was retried in the last 24h', + for (final receipt in receipts) { + if (receipt.retryCount > 1 && receipt.lastRetry != null) { + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), ); - continue; - } - } - var messageId = receipt.messageId; - if (receipt.messageId == null) { - Log.info('Message not in receipt. Loading it from the content.'); - try { - final content = EncryptedContent.fromBuffer(receipt.message); - if (content.hasMedia()) { - messageId = content.media.senderMessageId; - final messageExists = await twonlyDB.messagesDao - .getMessageById(messageId) - .getSingleOrNull(); - if (messageExists != null) { - await twonlyDB.receiptsDao.updateReceipt( - receipt.receiptId, - ReceiptsCompanion( - messageId: Value(messageId), - ), - ); - } else { - Log.info( - 'Message $messageId not found in DB for receipt recovery. Deleting stale receipt.', - ); - await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); - continue; - } - } - } catch (e) { - Log.error(e); - } - } - if (messageId == null) { - Log.error('MessageId is empty for media file receipts'); - continue; - } - if (receipt.markForRetryAfterAccepted != null) { - if (!contacts.containsKey(receipt.contactId)) { - final contact = await twonlyDB.contactsDao - .getContactByUserId(receipt.contactId) - .getSingleOrNull(); - if (contact == null) { - Log.error( - 'Contact does not exists, but has a record in receipts, this should not be possible, because of the DELETE CASCADE relation.', + if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) { + Log.info( + 'Ignoring ${receipt.receiptId} as it was retried in the last 24h', ); continue; } - contacts[receipt.contactId] = contact; } - if (!(contacts[receipt.contactId]?.accepted ?? true)) { - Log.warn( - 'Could not send message as contact has still not yet accepted.', - ); - continue; - } - } - - if (receipt.ackByServerAt == null) { - // media file must be reuploaded again in case the media files - // was deleted by the server, the receiver will request a new media reupload - - final message = await twonlyDB.messagesDao - .getMessageById(messageId) - .getSingleOrNull(); - if (message == null || message.mediaId == null) { - // The message or media file does not exists any more, so delete the receipt... - if (message != null) { - // The media file of the message does not exist anymore. Removing it... - await twonlyDB.messagesDao.deleteMessagesById(messageId); + var messageId = receipt.messageId; + if (receipt.messageId == null) { + Log.info('Message not in receipt. Loading it from the content.'); + try { + final content = EncryptedContent.fromBuffer(receipt.message); + if (content.hasMedia()) { + messageId = content.media.senderMessageId; + final messageExists = await twonlyDB.messagesDao + .getMessageById(messageId) + .getSingleOrNull(); + if (messageExists != null) { + await twonlyDB.receiptsDao.updateReceipt( + receipt.receiptId, + ReceiptsCompanion( + messageId: Value(messageId), + ), + ); + } else { + Log.info( + 'Message $messageId not found in DB for receipt recovery. Deleting stale receipt.', + ); + await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); + continue; + } + } + } catch (e) { + Log.error(e); } - await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); - Log.warn( - 'Message not found for reupload of the receipt, likely deleted from sender (${message == null} - ${message?.mediaId}).', - ); + } + if (messageId == null) { + Log.error('MessageId is empty for media file receipts'); continue; } + if (receipt.markForRetryAfterAccepted != null) { + if (!contacts.containsKey(receipt.contactId)) { + final contact = await twonlyDB.contactsDao + .getContactByUserId(receipt.contactId) + .getSingleOrNull(); + if (contact == null) { + Log.error( + 'Contact does not exists, but has a record in receipts, this should not be possible, because of the DELETE CASCADE relation.', + ); + continue; + } + contacts[receipt.contactId] = contact; + } + if (!(contacts[receipt.contactId]?.accepted ?? true)) { + Log.warn( + 'Could not send message as contact has still not yet accepted.', + ); + continue; + } + } - final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( - message.mediaId!, - ); - if (mediaFile == null) { - Log.error( - 'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).', + if (receipt.ackByServerAt == null) { + // media file must be reuploaded again in case the media files + // was deleted by the server, the receiver will request a new media reupload + + final message = await twonlyDB.messagesDao + .getMessageById(messageId) + .getSingleOrNull(); + if (message == null || message.mediaId == null) { + // The message or media file does not exists any more, so delete the receipt... + if (message != null) { + // The media file of the message does not exist anymore. Removing it... + await twonlyDB.messagesDao.deleteMessagesById(messageId); + } + await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); + Log.warn( + 'Message not found for reupload of the receipt, likely deleted from sender (${message == null} - ${message?.mediaId}).', + ); + continue; + } + + final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( + message.mediaId!, ); - continue; + if (mediaFile == null) { + Log.error( + 'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).', + ); + continue; + } + await reuploadMediaFile( + receipt.contactId, + mediaFile, + message.messageId, + ); + } else { + Log.info('Reuploading media file $messageId'); + // the media file should be still on the server, so it should be enough + // to just resend the message containing the download token. + await tryToSendCompleteMessage(receipt: receipt); } - await reuploadMediaFile( - receipt.contactId, - mediaFile, - message.messageId, - ); - } else { - Log.info('Reuploading media file $messageId'); - // the media file should be still on the server, so it should be enough - // to just resend the message containing the download token. - await tryToSendCompleteMessage(receipt: receipt); } - } - }); + }, + ); } Future reuploadMediaFile( @@ -187,69 +192,77 @@ Future reuploadMediaFile( } } +final Mutex _lockPreprocessing = Mutex(); + Future finishStartedPreprocessing() async { - final mediaFiles = await twonlyDB.mediaFilesDao - .getAllMediaFilesPendingUpload(); + return exclusiveAccess( + lockName: 'preprocessing_maintenance', + mutex: _lockPreprocessing, + action: () async { + final mediaFiles = await twonlyDB.mediaFilesDao + .getAllMediaFilesPendingUpload(); - for (final mediaFile in mediaFiles) { - if (mediaFile.isDraftMedia) { - Log.info('Ignoring media files as it is a draft'); - continue; - } - try { - final service = MediaFileService(mediaFile); - if (!service.originalPath.existsSync() && - !service.uploadRequestPath.existsSync()) { - if (service.storedPath.existsSync()) { - // media files was just stored.. - continue; - } - if (mediaFile.reuploadRequestedBy != null) { - Log.warn( - 'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.', - ); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploaded), - reuploadRequestedBy: Value(null), - ), - ); + for (final mediaFile in mediaFiles) { + if (mediaFile.isDraftMedia) { + Log.info('Ignoring media files as it is a draft'); continue; } + try { + final service = MediaFileService(mediaFile); + if (!service.originalPath.existsSync() && + !service.uploadRequestPath.existsSync()) { + if (service.storedPath.existsSync()) { + // media files was just stored.. + continue; + } + if (mediaFile.reuploadRequestedBy != null) { + Log.warn( + 'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.', + ); + await twonlyDB.mediaFilesDao.updateMedia( + mediaFile.mediaId, + const MediaFilesCompanion( + uploadState: Value(UploadState.uploaded), + reuploadRequestedBy: Value(null), + ), + ); + continue; + } - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - mediaFile.mediaId, - ); + final messages = await twonlyDB.messagesDao.getMessagesByMediaId( + mediaFile.mediaId, + ); - if (messages.isEmpty) { + if (messages.isEmpty) { + Log.info( + 'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.', + ); + // the file does not exists anymore and no messages reference it. + await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); + } else { + Log.warn( + 'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.', + ); + // Just mark as uploaded to stop preprocessing attempts + await twonlyDB.mediaFilesDao.updateMedia( + mediaFile.mediaId, + const MediaFilesCompanion( + uploadState: Value(UploadState.uploaded), + ), + ); + } + continue; + } Log.info( - 'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.', - ); - // the file does not exists anymore and no messages reference it. - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); - } else { - Log.warn( - 'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.', - ); - // Just mark as uploaded to stop preprocessing attempts - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploaded), - ), + 'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.', ); + await startBackgroundMediaUpload(service); + } catch (e) { + Log.warn(e); } - continue; } - Log.info( - 'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.', - ); - await startBackgroundMediaUpload(service); - } catch (e) { - Log.warn(e); - } - } + }, + ); } /// It can happen, that a media files is uploaded but not yet marked for been uploaded. diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart index 249a10fe..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,26 +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 { + 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(); @@ -50,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 @@ -63,7 +78,7 @@ Future initBackgroundExecution() async { return false; } - AppState.isInBackgroundTask = true; + Log.info('Background task is initialized'); _isInitialized = true; return true; diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 7f08c4f4..ec914ade 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -33,39 +33,58 @@ class MediaFileService { ); final files = tempDirectory.listSync(); + if (files.isEmpty) return; + + final mediaIdToFile = >{}; for (final file in files) { final mediaId = basename(file.path).split('.').first; + mediaIdToFile.putIfAbsent(mediaId, () => []).add(file); + } + final mediaIds = mediaIdToFile.keys.toList(); + + // Bulk fetch media files and messages + final allMediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( + mediaIds, + ); + final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds( + mediaIds, + ); + + final mediaFileMap = {for (final m in allMediaFiles) m.mediaId: m}; + final messageMap = >{}; + for (final msg in allMessages) { + if (msg.mediaId != null) { + messageMap.putIfAbsent(msg.mediaId!, () => []).add(msg); + } + } + + for (final mediaId in mediaIds) { // in case the mediaID is unknown the file will be deleted var delete = true; - final service = await MediaFileService.fromMediaId(mediaId); + final mediaFile = mediaFileMap[mediaId]; - if (service != null) { - if (service.mediaFile.isDraftMedia) { + if (mediaFile != null) { + if (mediaFile.isDraftMedia) { delete = false; } - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - mediaId, - ); + final messages = messageMap[mediaId] ?? []; // in case messages in empty the file will be deleted, as delete is true by default for (final message in messages) { - if (service.mediaFile.type == MediaType.audio) { + if (mediaFile.type == MediaType.audio) { delete = false; // do not delete voice messages } if (message.openedAt == null) { // Message was not yet opened from all persons, so wait... delete = false; - } else if (service.mediaFile.requiresAuthentication || - service.mediaFile.displayLimitInMilliseconds != null) { + } else if (mediaFile.requiresAuthentication || + mediaFile.displayLimitInMilliseconds != null) { // Message was opened by all persons, and they can not reopen the image. - // This branch will prevent to reach the next if condition, with would otherwise store the image for two days - // delete = true; // do not overwrite a previous delete = false - // this is just to make it easier to understand :) } else if (message.openedAt!.isAfter( clock.now().subtract(const Duration(days: 2)), )) { @@ -89,11 +108,20 @@ class MediaFileService { if (delete) { Log.info('Purging media file $mediaId'); - file.deleteSync(); + final filesToPurge = mediaIdToFile[mediaId] ?? []; + for (final file in filesToPurge) { + try { + if (file.existsSync()) { + file.deleteSync(); + } + } catch (e) { + Log.error('Error deleting file ${file.path}: $e'); + } + } } } } catch (e) { - Log.error(e); + Log.error('Error in purgeTempFolder: $e'); } } 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 abf019bf..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} [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/components/verification_badge.comp.dart b/lib/src/visual/components/verification_badge.comp.dart index 8a7bc422..ec655b44 100644 --- a/lib/src/visual/components/verification_badge.comp.dart +++ b/lib/src/visual/components/verification_badge.comp.dart @@ -6,7 +6,6 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/visual/components/verification_badge_info.comp.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart'; @@ -65,7 +64,6 @@ class _VerificationBadgeCompState extends State { .listen((update) { if (!mounted) return; setState(() { - Log.info('Update: ${update.length}'); _isVerified = update.isNotEmpty; }); }); diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 62d8e1e8..0a522354 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -40,8 +40,8 @@ class ChatMessagesView extends StatefulWidget { class _ChatMessagesViewState extends State with WidgetsBindingObserver { HashSet alreadyReportedOpened = HashSet(); - late StreamSubscription userSub; - late StreamSubscription> messageSub; + StreamSubscription? userSub; + StreamSubscription>? messageSub; StreamSubscription>? groupActionsSub; StreamSubscription>? contactSub; @@ -55,7 +55,7 @@ class _ChatMessagesViewState extends State List galleryItems = []; Message? quotesMessage; GlobalKey verifyShieldKey = GlobalKey(); - late FocusNode textFieldFocus; + FocusNode? textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; bool _receiverDeletedAccount = false; @@ -72,11 +72,12 @@ class _ChatMessagesViewState extends State @override void dispose() { - userSub.cancel(); - messageSub.cancel(); + userSub?.cancel(); + messageSub?.cancel(); contactSub?.cancel(); groupActionsSub?.cancel(); _nextTypingIndicator?.cancel(); + textFieldFocus?.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -351,7 +352,7 @@ class _ChatMessagesViewState extends State setState(() { quotesMessage = chatMessage; }); - textFieldFocus.requestFocus(); + textFieldFocus?.requestFocus(); }, ), ); @@ -394,7 +395,7 @@ class _ChatMessagesViewState extends State MessageInput( group: group, quotesMessage: quotesMessage, - textFieldFocus: textFieldFocus, + textFieldFocus: textFieldFocus!, onMessageSend: () { setState(() { quotesMessage = null; diff --git a/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart b/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart index 804974b2..f7a67fb0 100644 --- a/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart +++ b/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart @@ -43,9 +43,8 @@ class TypingIndicator extends StatefulWidget { class _TypingIndicatorState extends State { List _groupMembers = []; - late StreamSubscription> membersSub; - - late Timer _periodicUpdate; + StreamSubscription>? membersSub; + Timer? _periodicUpdate; @override void initState() { @@ -73,8 +72,8 @@ class _TypingIndicatorState extends State { @override void dispose() { - membersSub.cancel(); - _periodicUpdate.cancel(); + membersSub?.cancel(); + _periodicUpdate?.cancel(); super.dispose(); } @@ -138,12 +137,12 @@ class AnimatedTypingDots extends StatefulWidget { class _AnimatedTypingDotsState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - - late List> _animations; + AnimationController? _controller; + List>? _animations; @override void initState() { + super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, @@ -172,29 +171,30 @@ class _AnimatedTypingDotsState extends State ), ]).animate( CurvedAnimation( - parent: _controller, + parent: _controller!, curve: Interval(start, end), ), ); }); - super.initState(); } @override void dispose() { - _controller.dispose(); + _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + if (_animations == null) return const SizedBox.shrink(); + return Row( mainAxisSize: MainAxisSize.min, children: List.generate( 3, (index) => _AnimatedDot( isTyping: widget.isTyping, - animation: _animations[index], + animation: _animations![index], ), ), ); diff --git a/lib/src/visual/views/settings/help/diagnostics.view.dart b/lib/src/visual/views/settings/help/diagnostics.view.dart index 1ff5573e..f664368f 100644 --- a/lib/src/visual/views/settings/help/diagnostics.view.dart +++ b/lib/src/visual/views/settings/help/diagnostics.view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/utils/log.dart'; @@ -157,25 +158,39 @@ class _LogViewerWidgetState extends State { final tsStyle = TextStyle( color: isDarkMode(context) ? Colors.white : Colors.black, fontFamily: 'monospace', + fontSize: 12, ); - final levelStyle = TextStyle( - color: Colors.blueGrey.shade600, + final fileNameStyle = TextStyle( + color: Colors.blueGrey.shade400, fontWeight: FontWeight.bold, fontFamily: 'monospace', + fontSize: 11, ); final msgStyle = TextStyle( color: isDarkMode(context) ? Colors.white : Colors.black, fontFamily: 'monospace', + fontSize: 13, ); return TextSpan( children: [ if (_showTimestamps && e.timestamp != null) TextSpan( - text: '${e.timestamp} '.replaceAll('.000', ''), + text: '${e.timestamp.toString().split(' ')[1]} ', style: tsStyle, ), - TextSpan(text: '${e.fileName}\n', style: levelStyle), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: FaIcon( + e.isBackground ? FontAwesomeIcons.clock : FontAwesomeIcons.mobile, + size: 12, + color: Colors.grey, + ), + ), + ), + TextSpan(text: '${e.fileName}\n', style: fileNameStyle), TextSpan(text: e.message, style: msgStyle), ], ); @@ -274,24 +289,26 @@ class _LogViewerWidgetState extends State { class _LogEntry { _LogEntry({ + required this.timestamp, + required this.level, required this.message, required this.line, required this.fileName, - this.timestamp, - this.level, + required this.isBackground, }); // Minimal parser based on the sample log format factory _LogEntry.parse(String raw) { // Example line: - // 2025-12-25 23:36:52 WARNING [twonly] api.service.dart:189) > websocket error: ... + // 2025-12-25 23:36:52 WARNING [f] [twonly] api.service.dart:189) > websocket error: ... final trimmed = raw.trim(); DateTime? ts; 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 { @@ -318,11 +335,17 @@ class _LogEntry { } } - msg = msg.trim().replaceAll('[twonly] ', ''); + final isBackground = msg.contains('[b] '); + + msg = msg + .trim() + .replaceAll('[twonly] ', '') + .replaceAll('[f] ', '') + .replaceAll('[b] ', ''); + final fileNameS = msg.split(' > '); final fileName = fileNameS[0]; - - msg = fileNameS.sublist(1).join(); + msg = fileNameS.sublist(1).join(' > '); return _LogEntry( timestamp: ts, @@ -330,11 +353,14 @@ class _LogEntry { message: msg, line: raw, fileName: fileName, + isBackground: isBackground, ); } + final DateTime? timestamp; final String? level; final String message; final String line; final String fileName; + final bool isBackground; } diff --git a/pubspec.yaml b/pubspec.yaml index 7b75d1a6..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.2+111 +version: 0.2.8+117 environment: sdk: ^3.11.0 diff --git a/rust/src/bridge/callbacks/user_discovery.rs b/rust/src/bridge/callbacks/user_discovery.rs index 68fd03cf..5d57b4bc 100644 --- a/rust/src/bridge/callbacks/user_discovery.rs +++ b/rust/src/bridge/callbacks/user_discovery.rs @@ -54,7 +54,6 @@ impl UserDiscoveryStore for UserDiscoveryStoreFlutter { return Err(UserDiscoveryError::NotInitialized); } - tracing::debug!("Loading Config from {}", config_path.display()); Ok(std::fs::read_to_string(&config_path)?) } 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.");