Merge pull request #404 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled

Fix: App did not launch sometimes on Android
This commit is contained in:
Tobi 2026-05-02 16:58:21 +02:00 committed by GitHub
commit c9eb270324
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 478 additions and 268 deletions

View file

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.2.8
- Fix: App did not launch sometimes on Android
## 0.2.0 ## 0.2.0
- New: Feature to find friends without a phone number - New: Feature to find friends without a phone number

View file

@ -18,6 +18,7 @@ analyzer:
- "lib/generated/**" - "lib/generated/**"
- "lib/core/**" - "lib/core/**"
- "lib/src/localization/**" - "lib/src/localization/**"
- "rust_builder/"
- "dependencies/**" - "dependencies/**"
- "pubspec.yaml" - "pubspec.yaml"
- "**.arb" - "**.arb"

View file

@ -8,7 +8,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@ -120,18 +119,21 @@ class _AppMainWidgetState extends State<AppMainWidget> {
bool _showOnboarding = true; bool _showOnboarding = true;
bool _isLoaded = false; bool _isLoaded = false;
bool _isTwonlyLocked = true; bool _isTwonlyLocked = true;
bool _wasLogged = true;
(Future<int>?, bool) _proofOfWork = (null, false); (Future<int>?, bool) _proofOfWork = (null, false);
@override @override
void initState() { void initState() {
initAsync();
super.initState(); super.initState();
Log.info('AppWidgetState: initState started');
initAsync();
} }
Future<void> initAsync() async { Future<void> initAsync() async {
Log.info('AppWidgetState: initAsync started');
if (userService.isUserCreated) { if (userService.isUserCreated) {
await FirebaseMessaging.instance.requestPermission(); unawaited(FirebaseMessaging.instance.requestPermission());
if (_isTwonlyLocked) { if (_isTwonlyLocked) {
// do not change in case twonly was already unlocked at some point // do not change in case twonly was already unlocked at some point
_isTwonlyLocked = userService.currentUser.screenLockEnabled; _isTwonlyLocked = userService.currentUser.screenLockEnabled;
@ -158,6 +160,12 @@ class _AppMainWidgetState extends State<AppMainWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_wasLogged) {
Log.info('AppWidgetState: build started (_isLoaded: $_isLoaded)');
if (_isLoaded) {
_wasLogged = true;
}
}
if (!_isLoaded) { if (!_isLoaded) {
return Center(child: Container()); return Center(child: Container());
} }

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/utils/log.dart';
class AppEnvironment { class AppEnvironment {
static late final String cacheDir; static late final String cacheDir;
@ -14,6 +15,7 @@ class AppEnvironment {
static Future<void> init() async { static Future<void> init() async {
cacheDir = (await getApplicationCacheDirectory()).path; cacheDir = (await getApplicationCacheDirectory()).path;
supportDir = (await getApplicationSupportDirectory()).path; supportDir = (await getApplicationSupportDirectory()).path;
Log.init();
} }
static void initTesting() { static void initTesting() {

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mutex/mutex.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/app.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.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/avatars.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/log.dart';
import 'package:twonly/src/utils/secure_storage.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'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
final _initMutex = Mutex();
/// This function is used to initialized the absolute minimum so it /// This function is used to initialized the absolute minimum so it
/// can also be used by the backend without the UI was loaded. /// can also be used by the backend without the UI was loaded.
Future<void> twonlyMinimumInitialization() async { Future<void> 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.info('twonlyMinimumInitialization: RustLib.init()');
Log.init(); await RustLib.init();
setupLocator();
await RustLib.init(); Log.info('twonlyMinimumInitialization: initFlutterCallbacksForRust()');
await initFlutterCallbacksForRust();
await initFlutterCallbacksForRust(); Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()');
await bridge.initializeTwonlyFlutter(
await bridge.initializeTwonlyFlutter( config: bridge.TwonlyConfig(
config: bridge.TwonlyConfig( databasePath: '${AppEnvironment.supportDir}/twonly.sqlite',
databasePath: '${AppEnvironment.supportDir}/twonly.sqlite', dataDirectory: AppEnvironment.supportDir,
dataDirectory: AppEnvironment.supportDir, ),
), );
Log.info('twonlyMinimumInitialization: finished');
},
); );
} }
void main() async { void main() async {
final binding = SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init();
final stopwatch = Stopwatch()..start();
unawaited(StartupGuard.markAppStartup());
await twonlyMinimumInitialization(); await twonlyMinimumInitialization();
unawaited(initFCMService()); unawaited(initFCMService());
@ -67,21 +86,20 @@ void main() async {
storageError = true; storageError = true;
} }
final dbExists = File(
'${AppEnvironment.supportDir}/twonly.sqlite',
).existsSync();
if (Platform.isIOS && userExists) { 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...'); Log.error('[twonly] IOS: App was removed and then reinstalled again...');
await SecureStorage.instance.deleteAll(); await SecureStorage.instance.deleteAll();
userExists = false; userExists = false;
} }
} }
Log.info('User loaded.');
final settingsController = SettingsChangeProvider()..loadSettings(); final settingsController = SettingsChangeProvider()..loadSettings();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await initFileDownloader(); unawaited(initFileDownloader());
if (userExists) { if (userExists) {
if (userService.currentUser.allowErrorTrackingViaSentry) { if (userService.currentUser.allowErrorTrackingViaSentry) {
@ -96,22 +114,22 @@ void main() async {
} }
await runMigrations(); await runMigrations();
// We wait for the first frame to be rendered before starting heavy tasks.
await twonlyDB.messagesDao.purgeMessageTable(); // This ensures the splash screen is dismissed on Android immediately.
await twonlyDB.receiptsDao.purgeReceivedReceipts(); binding.addPostFrameCallback((_) async {
await UserDiscoveryService.removeDeletedContacts(); await Future.delayed(const Duration(seconds: 1));
unawaited(postStartupTasks());
unawaited(MediaFileService.purgeTempFolder()); unawaited(apiService.connect());
});
unawaited(setupPushNotification());
unawaited(finishStartedPreprocessing());
unawaited(createPushAvatars());
unawaited(performTwonlySafeBackup());
unawaited(initializeBackgroundTaskManager());
} }
await apiService.listenToNetworkChanges(); await apiService.listenToNetworkChanges();
unawaited(apiService.connect());
stopwatch.stop();
Log.info(
'Initialization finished after ${stopwatch.elapsed}. Calling runApp...',
);
runApp( runApp(
MultiProvider( MultiProvider(
@ -161,3 +179,24 @@ Future<void> runMigrations() async {
}); });
} }
} }
Future<void> 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());
}

View file

@ -19,7 +19,6 @@ class LoggingCallbacks {
print(log); print(log);
} }
}, },
onDone: () => Log.info('Log stream closed'),
); );
timer.cancel(); timer.cancel();
} catch (e) { } catch (e) {

View file

@ -65,6 +65,11 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
)..where((t) => t.mediaId.equals(mediaId))).getSingleOrNull(); )..where((t) => t.mediaId.equals(mediaId))).getSingleOrNull();
} }
Future<List<MediaFile>> getMediaFilesByIds(List<String> mediaIds) async {
return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get();
}
Future<MediaFile?> getDraftMediaFile() async { Future<MediaFile?> getDraftMediaFile() async {
final medias = await (select( final medias = await (select(
mediaFiles, mediaFiles,

View file

@ -140,15 +140,22 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
Future<void> purgeMessageTable() async { Future<void> purgeMessageTable() async {
final allGroups = await select(groups).get(); final allGroups = await select(groups).get();
for (final group in allGroups) { final groupedByTime = <int, List<String>>{};
for (final g in allGroups) {
groupedByTime
.putIfAbsent(g.deleteMessagesAfterMilliseconds, () => [])
.add(g.groupId);
}
for (final entry in groupedByTime.entries) {
final deletionTime = clock.now().subtract( final deletionTime = clock.now().subtract(
Duration( Duration(milliseconds: entry.key),
milliseconds: group.deleteMessagesAfterMilliseconds,
),
); );
final groupIds = entry.value;
await (delete(messages)..where( await (delete(messages)..where(
(m) => (m) =>
m.groupId.equals(group.groupId) & m.groupId.isIn(groupIds) &
(m.mediaStored.equals(true) & (m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) | m.isDeletedFromSender.equals(true) |
m.mediaStored.equals(false)) & m.mediaStored.equals(false)) &
@ -404,6 +411,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return (select(messages)..where((t) => t.mediaId.equals(mediaId))).get(); return (select(messages)..where((t) => t.mediaId.equals(mediaId))).get();
} }
Future<List<Message>> getMessagesByMediaIds(List<String> mediaIds) async {
return (select(messages)..where((t) => t.mediaId.isIn(mediaIds))).get();
}
Stream<List<(MessageAction, Contact)>> watchMessageActions(String messageId) { Stream<List<(MessageAction, Contact)>> watchMessageActions(String messageId) {
final query = (select(messageActions).join([ final query = (select(messageActions).join([
leftOuterJoin( leftOuterJoin(

View file

@ -54,6 +54,7 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
} }
loadPurchases(); loadPurchases();
Log.info('PurchasesProvider: constructor finished');
} }
SubscriptionPlan plan = SubscriptionPlan.Free; SubscriptionPlan plan = SubscriptionPlan.Free;
@ -74,6 +75,7 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
Future<void> loadPurchases() async { Future<void> loadPurchases() async {
final available = await iapConnection.isAvailable(); final available = await iapConnection.isAvailable();
Log.info('PurchasesProvider: IAP available: $available');
if (!available) { if (!available) {
storeState = StoreState.notAvailable; storeState = StoreState.notAvailable;
Log.warn('Store is not available'); Log.warn('Store is not available');

View file

@ -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/api/messages.api.dart';
import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.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/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/secure_storage.dart';
@ -31,124 +32,128 @@ import 'package:workmanager/workmanager.dart' hide TaskStatus;
final lockRetransmission = Mutex(); final lockRetransmission = Mutex();
Future<void> reuploadMediaFiles() async { Future<void> reuploadMediaFiles() async {
return lockRetransmission.protect(() async { return exclusiveAccess(
final receipts = await twonlyDB.receiptsDao lockName: 'reupload_maintenance',
.getReceiptsForMediaRetransmissions(); 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 = <int, Contact>{}; final contacts = <int, Contact>{};
for (final receipt in receipts) { for (final receipt in receipts) {
if (receipt.retryCount > 1 && receipt.lastRetry != null) { if (receipt.retryCount > 1 && receipt.lastRetry != null) {
final twentyFourHoursAgo = DateTime.now().subtract( final twentyFourHoursAgo = DateTime.now().subtract(
const Duration(hours: 24), const Duration(hours: 24),
);
if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) {
Log.info(
'Ignoring ${receipt.receiptId} as it was retried in the last 24h',
); );
continue; if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) {
} Log.info(
} 'Ignoring ${receipt.receiptId} as it was retried in the last 24h',
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.',
); );
continue; continue;
} }
contacts[receipt.contactId] = contact;
} }
if (!(contacts[receipt.contactId]?.accepted ?? true)) { var messageId = receipt.messageId;
Log.warn( if (receipt.messageId == null) {
'Could not send message as contact has still not yet accepted.', Log.info('Message not in receipt. Loading it from the content.');
); try {
continue; final content = EncryptedContent.fromBuffer(receipt.message);
} if (content.hasMedia()) {
} messageId = content.media.senderMessageId;
final messageExists = await twonlyDB.messagesDao
if (receipt.ackByServerAt == null) { .getMessageById(messageId)
// media file must be reuploaded again in case the media files .getSingleOrNull();
// was deleted by the server, the receiver will request a new media reupload if (messageExists != null) {
await twonlyDB.receiptsDao.updateReceipt(
final message = await twonlyDB.messagesDao receipt.receiptId,
.getMessageById(messageId) ReceiptsCompanion(
.getSingleOrNull(); messageId: Value(messageId),
if (message == null || message.mediaId == null) { ),
// The message or media file does not exists any more, so delete the receipt... );
if (message != null) { } else {
// The media file of the message does not exist anymore. Removing it... Log.info(
await twonlyDB.messagesDao.deleteMessagesById(messageId); '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( if (messageId == null) {
'Message not found for reupload of the receipt, likely deleted from sender (${message == null} - ${message?.mediaId}).', Log.error('MessageId is empty for media file receipts');
);
continue; 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( if (receipt.ackByServerAt == null) {
message.mediaId!, // media file must be reuploaded again in case the media files
); // was deleted by the server, the receiver will request a new media reupload
if (mediaFile == null) {
Log.error( final message = await twonlyDB.messagesDao
'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).', .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<void> reuploadMediaFile( Future<void> reuploadMediaFile(
@ -187,69 +192,77 @@ Future<void> reuploadMediaFile(
} }
} }
final Mutex _lockPreprocessing = Mutex();
Future<void> finishStartedPreprocessing() async { Future<void> finishStartedPreprocessing() async {
final mediaFiles = await twonlyDB.mediaFilesDao return exclusiveAccess(
.getAllMediaFilesPendingUpload(); lockName: 'preprocessing_maintenance',
mutex: _lockPreprocessing,
action: () async {
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingUpload();
for (final mediaFile in mediaFiles) { for (final mediaFile in mediaFiles) {
if (mediaFile.isDraftMedia) { if (mediaFile.isDraftMedia) {
Log.info('Ignoring media files as it is a draft'); 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; 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( final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaFile.mediaId, 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( Log.info(
'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.', 'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.',
);
// 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),
),
); );
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. /// It can happen, that a media files is uploaded but not yet marked for been uploaded.

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/main.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/exclusive_access.utils.dart';
import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/startup_guard.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
// ignore: unreachable_from_main // ignore: unreachable_from_main
@ -16,26 +18,27 @@ Future<void> initializeBackgroundTaskManager() async {
await Workmanager().initialize(callbackDispatcher); await Workmanager().initialize(callbackDispatcher);
await Workmanager().cancelByUniqueName('fetch_data_from_server'); await Workmanager().cancelByUniqueName('fetch_data_from_server');
await Workmanager().registerPeriodicTask( // await Workmanager().registerPeriodicTask(
'fetch_data_from_server', // 'fetch_data_from_server',
'eu.twonly.periodic_task', // 'eu.twonly.periodic_task',
frequency: const Duration(minutes: 20), // frequency: const Duration(minutes: 20),
initialDelay: const Duration(minutes: 5), // initialDelay: const Duration(minutes: 5),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update, // existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
constraints: Constraints( // constraints: Constraints(
networkType: NetworkType.connected, // networkType: NetworkType.connected,
), // ),
); // );
} }
@pragma('vm:entry-point') @pragma('vm:entry-point')
void callbackDispatcher() { void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
SentryWidgetsFlutterBinding.ensureInitialized();
switch (task) { switch (task) {
case 'eu.twonly.periodic_task': case 'eu.twonly.periodic_task':
if (await initBackgroundExecution()) { // if (await initBackgroundExecution()) {
await handlePeriodicTask(); // await handlePeriodicTask();
} // }
case 'eu.twonly.processing_task': case 'eu.twonly.processing_task':
if (await initBackgroundExecution()) { if (await initBackgroundExecution()) {
await handleProcessingTask(); await handleProcessingTask();
@ -50,6 +53,18 @@ void callbackDispatcher() {
bool _isInitialized = false; bool _isInitialized = false;
Future<bool> initBackgroundExecution() async { Future<bool> 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) { if (_isInitialized) {
// Reload the users, as on Android the background isolate can // Reload the users, as on Android the background isolate can
// stay alive for multiple hours between task executions // stay alive for multiple hours between task executions
@ -63,7 +78,7 @@ Future<bool> initBackgroundExecution() async {
return false; return false;
} }
AppState.isInBackgroundTask = true; Log.info('Background task is initialized');
_isInitialized = true; _isInitialized = true;
return true; return true;

View file

@ -33,39 +33,58 @@ class MediaFileService {
); );
final files = tempDirectory.listSync(); final files = tempDirectory.listSync();
if (files.isEmpty) return;
final mediaIdToFile = <String, List<FileSystemEntity>>{};
for (final file in files) { for (final file in files) {
final mediaId = basename(file.path).split('.').first; 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 = <String, List<Message>>{};
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 // in case the mediaID is unknown the file will be deleted
var delete = true; var delete = true;
final service = await MediaFileService.fromMediaId(mediaId); final mediaFile = mediaFileMap[mediaId];
if (service != null) { if (mediaFile != null) {
if (service.mediaFile.isDraftMedia) { if (mediaFile.isDraftMedia) {
delete = false; delete = false;
} }
final messages = await twonlyDB.messagesDao.getMessagesByMediaId( final messages = messageMap[mediaId] ?? [];
mediaId,
);
// in case messages in empty the file will be deleted, as delete is true by default // in case messages in empty the file will be deleted, as delete is true by default
for (final message in messages) { for (final message in messages) {
if (service.mediaFile.type == MediaType.audio) { if (mediaFile.type == MediaType.audio) {
delete = false; // do not delete voice messages delete = false; // do not delete voice messages
} }
if (message.openedAt == null) { if (message.openedAt == null) {
// Message was not yet opened from all persons, so wait... // Message was not yet opened from all persons, so wait...
delete = false; delete = false;
} else if (service.mediaFile.requiresAuthentication || } else if (mediaFile.requiresAuthentication ||
service.mediaFile.displayLimitInMilliseconds != null) { mediaFile.displayLimitInMilliseconds != null) {
// Message was opened by all persons, and they can not reopen the image. // 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( } else if (message.openedAt!.isAfter(
clock.now().subtract(const Duration(days: 2)), clock.now().subtract(const Duration(days: 2)),
)) { )) {
@ -89,11 +108,20 @@ class MediaFileService {
if (delete) { if (delete) {
Log.info('Purging media file $mediaId'); 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) { } catch (e) {
Log.error(e); Log.error('Error in purgeTempFolder: $e');
} }
} }

View file

@ -7,6 +7,7 @@ import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart';
@ -117,6 +118,7 @@ Future<void> initFCMService() async {
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
SentryWidgetsFlutterBinding.ensureInitialized();
final isInitialized = await initBackgroundExecution(); final isInitialized = await initBackgroundExecution();
Log.info('Handling a background message: ${message.messageId}'); Log.info('Handling a background message: ${message.messageId}');
await handleRemoteMessage(message); await handleRemoteMessage(message);

View file

@ -24,7 +24,6 @@ class Log {
); );
} }
}); });
cleanLogFile();
} }
static String filterLogMessage(String msg) { static String filterLogMessage(String msg) {
@ -106,7 +105,7 @@ Future<void> _writeLogToFile(LogRecord record) async {
final logFile = File('${AppEnvironment.supportDir}/app.log'); final logFile = File('${AppEnvironment.supportDir}/app.log');
final logMessage = 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 { return _protectFileAccess(() async {
if (!logFile.existsSync()) { if (!logFile.existsSync()) {

View file

@ -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<File> _getLockFile() async {
final path = (await getApplicationCacheDirectory()).path;
return File('$path/app_startup.lock');
}
static Future<void> 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<bool> 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;
}
}
}

View file

@ -6,7 +6,6 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart';
import 'package:twonly/src/database/twonly.db.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/components/verification_badge_info.comp.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart';
@ -65,7 +64,6 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
.listen((update) { .listen((update) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
Log.info('Update: ${update.length}');
_isVerified = update.isNotEmpty; _isVerified = update.isNotEmpty;
}); });
}); });

View file

@ -40,8 +40,8 @@ class ChatMessagesView extends StatefulWidget {
class _ChatMessagesViewState extends State<ChatMessagesView> class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver { with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>(); HashSet<int> alreadyReportedOpened = HashSet<int>();
late StreamSubscription<Group?> userSub; StreamSubscription<Group?>? userSub;
late StreamSubscription<List<Message>> messageSub; StreamSubscription<List<Message>>? messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub; StreamSubscription<List<GroupHistory>>? groupActionsSub;
StreamSubscription<List<Contact>>? contactSub; StreamSubscription<List<Contact>>? contactSub;
@ -55,7 +55,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
List<MemoryItem> galleryItems = []; List<MemoryItem> galleryItems = [];
Message? quotesMessage; Message? quotesMessage;
GlobalKey verifyShieldKey = GlobalKey(); GlobalKey verifyShieldKey = GlobalKey();
late FocusNode textFieldFocus; FocusNode? textFieldFocus;
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
int? focusedScrollItem; int? focusedScrollItem;
bool _receiverDeletedAccount = false; bool _receiverDeletedAccount = false;
@ -72,11 +72,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
@override @override
void dispose() { void dispose() {
userSub.cancel(); userSub?.cancel();
messageSub.cancel(); messageSub?.cancel();
contactSub?.cancel(); contactSub?.cancel();
groupActionsSub?.cancel(); groupActionsSub?.cancel();
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
textFieldFocus?.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@ -351,7 +352,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
setState(() { setState(() {
quotesMessage = chatMessage; quotesMessage = chatMessage;
}); });
textFieldFocus.requestFocus(); textFieldFocus?.requestFocus();
}, },
), ),
); );
@ -394,7 +395,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
MessageInput( MessageInput(
group: group, group: group,
quotesMessage: quotesMessage, quotesMessage: quotesMessage,
textFieldFocus: textFieldFocus, textFieldFocus: textFieldFocus!,
onMessageSend: () { onMessageSend: () {
setState(() { setState(() {
quotesMessage = null; quotesMessage = null;

View file

@ -43,9 +43,8 @@ class TypingIndicator extends StatefulWidget {
class _TypingIndicatorState extends State<TypingIndicator> { class _TypingIndicatorState extends State<TypingIndicator> {
List<GroupMember> _groupMembers = []; List<GroupMember> _groupMembers = [];
late StreamSubscription<List<(Contact, GroupMember)>> membersSub; StreamSubscription<List<(Contact, GroupMember)>>? membersSub;
Timer? _periodicUpdate;
late Timer _periodicUpdate;
@override @override
void initState() { void initState() {
@ -73,8 +72,8 @@ class _TypingIndicatorState extends State<TypingIndicator> {
@override @override
void dispose() { void dispose() {
membersSub.cancel(); membersSub?.cancel();
_periodicUpdate.cancel(); _periodicUpdate?.cancel();
super.dispose(); super.dispose();
} }
@ -138,12 +137,12 @@ class AnimatedTypingDots extends StatefulWidget {
class _AnimatedTypingDotsState extends State<AnimatedTypingDots> class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; AnimationController? _controller;
List<Animation<double>>? _animations;
late List<Animation<double>> _animations;
@override @override
void initState() { void initState() {
super.initState();
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 1000),
vsync: this, vsync: this,
@ -172,29 +171,30 @@ class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
), ),
]).animate( ]).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller!,
curve: Interval(start, end), curve: Interval(start, end),
), ),
); );
}); });
super.initState();
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller?.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_animations == null) return const SizedBox.shrink();
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: List.generate( children: List.generate(
3, 3,
(index) => _AnimatedDot( (index) => _AnimatedDot(
isTyping: widget.isTyping, isTyping: widget.isTyping,
animation: _animations[index], animation: _animations![index],
), ),
), ),
); );

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -157,25 +158,39 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
final tsStyle = TextStyle( final tsStyle = TextStyle(
color: isDarkMode(context) ? Colors.white : Colors.black, color: isDarkMode(context) ? Colors.white : Colors.black,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12,
); );
final levelStyle = TextStyle( final fileNameStyle = TextStyle(
color: Colors.blueGrey.shade600, color: Colors.blueGrey.shade400,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 11,
); );
final msgStyle = TextStyle( final msgStyle = TextStyle(
color: isDarkMode(context) ? Colors.white : Colors.black, color: isDarkMode(context) ? Colors.white : Colors.black,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 13,
); );
return TextSpan( return TextSpan(
children: [ children: [
if (_showTimestamps && e.timestamp != null) if (_showTimestamps && e.timestamp != null)
TextSpan( TextSpan(
text: '${e.timestamp} '.replaceAll('.000', ''), text: '${e.timestamp.toString().split(' ')[1]} ',
style: tsStyle, 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), TextSpan(text: e.message, style: msgStyle),
], ],
); );
@ -274,24 +289,26 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
class _LogEntry { class _LogEntry {
_LogEntry({ _LogEntry({
required this.timestamp,
required this.level,
required this.message, required this.message,
required this.line, required this.line,
required this.fileName, required this.fileName,
this.timestamp, required this.isBackground,
this.level,
}); });
// Minimal parser based on the sample log format // Minimal parser based on the sample log format
factory _LogEntry.parse(String raw) { factory _LogEntry.parse(String raw) {
// Example line: // 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(); final trimmed = raw.trim();
DateTime? ts; DateTime? ts;
String? level; String? level;
var msg = trimmed; var msg = trimmed;
// Try to parse leading timestamp (YYYY-MM-DD HH:MM:SS) // 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})\s+(.*)$'); final tsRegex =
RegExp(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(.*)$');
final mTs = tsRegex.firstMatch(trimmed); final mTs = tsRegex.firstMatch(trimmed);
if (mTs != null) { if (mTs != null) {
try { 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 fileNameS = msg.split(' > ');
final fileName = fileNameS[0]; final fileName = fileNameS[0];
msg = fileNameS.sublist(1).join(' > ');
msg = fileNameS.sublist(1).join();
return _LogEntry( return _LogEntry(
timestamp: ts, timestamp: ts,
@ -330,11 +353,14 @@ class _LogEntry {
message: msg, message: msg,
line: raw, line: raw,
fileName: fileName, fileName: fileName,
isBackground: isBackground,
); );
} }
final DateTime? timestamp; final DateTime? timestamp;
final String? level; final String? level;
final String message; final String message;
final String line; final String line;
final String fileName; final String fileName;
final bool isBackground;
} }

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.2.2+111 version: 0.2.8+117
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0

View file

@ -54,7 +54,6 @@ impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
return Err(UserDiscoveryError::NotInitialized); return Err(UserDiscoveryError::NotInitialized);
} }
tracing::debug!("Loading Config from {}", config_path.display());
Ok(std::fs::read_to_string(&config_path)?) Ok(std::fs::read_to_string(&config_path)?)
} }

View file

@ -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) { 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) .rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("twonly") .filename_prefix("twonly")
.filename_suffix("log") .filename_suffix("log")
.build(logs_dir) .build(logs_dir);
.expect("Failed to create file appender");
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()); let (non_blocking_stdout, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
TRACING_GUARDS if let Some(fg) = file_guard {
.set(Mutex::new(Some((file_guard, stdout_guard)))) TRACING_GUARDS
.ok(); .set(Mutex::new(Some((fg, stdout_guard))))
.ok();
}
(non_blocking_stdout, non_blocking_file) (non_blocking_stdout, non_blocking_file)
} }

View file

@ -57,6 +57,10 @@ pub(super) fn get_twonly_flutter() -> Result<&'static TwonlyFlutter> {
} }
pub async fn initialize_twonly_flutter(config: TwonlyConfig) -> Result<()> { 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"); let log_dir = PathBuf::from(&config.data_directory).join("log");
init_tracing(&log_dir, true).await; init_tracing(&log_dir, true).await;
tracing::info!("Initialized twonly workspace."); tracing::info!("Initialized twonly workspace.");