mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 01:32:13 +00:00
Merge pull request #404 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled
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:
commit
c9eb270324
24 changed files with 478 additions and 268 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ analyzer:
|
|||
- "lib/generated/**"
|
||||
- "lib/core/**"
|
||||
- "lib/src/localization/**"
|
||||
- "rust_builder/"
|
||||
- "dependencies/**"
|
||||
- "pubspec.yaml"
|
||||
- "**.arb"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
|
|
|||
14
lib/app.dart
14
lib/app.dart
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
|
@ -120,18 +119,21 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
|||
bool _showOnboarding = true;
|
||||
bool _isLoaded = false;
|
||||
bool _isTwonlyLocked = true;
|
||||
bool _wasLogged = true;
|
||||
|
||||
(Future<int>?, bool) _proofOfWork = (null, false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
initAsync();
|
||||
super.initState();
|
||||
Log.info('AppWidgetState: initState started');
|
||||
initAsync();
|
||||
}
|
||||
|
||||
Future<void> 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<AppMainWidget> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_wasLogged) {
|
||||
Log.info('AppWidgetState: build started (_isLoaded: $_isLoaded)');
|
||||
if (_isLoaded) {
|
||||
_wasLogged = true;
|
||||
}
|
||||
}
|
||||
if (!_isLoaded) {
|
||||
return Center(child: Container());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> init() async {
|
||||
cacheDir = (await getApplicationCacheDirectory()).path;
|
||||
supportDir = (await getApplicationSupportDirectory()).path;
|
||||
Log.init();
|
||||
}
|
||||
|
||||
static void initTesting() {
|
||||
|
|
|
|||
|
|
@ -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<void> twonlyMinimumInitialization() async {
|
||||
SentryWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await AppEnvironment.init();
|
||||
Log.init();
|
||||
Log.info('twonlyMinimumInitialization: called');
|
||||
await exclusiveAccess(
|
||||
lockName: 'init',
|
||||
mutex: _initMutex,
|
||||
action: () async {
|
||||
Log.info('twonlyMinimumInitialization: started');
|
||||
setupLocator();
|
||||
|
||||
Log.info('twonlyMinimumInitialization: RustLib.init()');
|
||||
await RustLib.init();
|
||||
|
||||
Log.info('twonlyMinimumInitialization: initFlutterCallbacksForRust()');
|
||||
await initFlutterCallbacksForRust();
|
||||
|
||||
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<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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class LoggingCallbacks {
|
|||
print(log);
|
||||
}
|
||||
},
|
||||
onDone: () => Log.info('Log stream closed'),
|
||||
);
|
||||
timer.cancel();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
)..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 {
|
||||
final medias = await (select(
|
||||
mediaFiles,
|
||||
|
|
|
|||
|
|
@ -140,15 +140,22 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
Future<void> purgeMessageTable() async {
|
||||
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(
|
||||
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<TwonlyDB> with _$MessagesDaoMixin {
|
|||
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) {
|
||||
final query = (select(messageActions).join([
|
||||
leftOuterJoin(
|
||||
|
|
|
|||
|
|
@ -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<void> loadPurchases() async {
|
||||
final available = await iapConnection.isAvailable();
|
||||
Log.info('PurchasesProvider: IAP available: $available');
|
||||
if (!available) {
|
||||
storeState = StoreState.notAvailable;
|
||||
Log.warn('Store is not available');
|
||||
|
|
|
|||
|
|
@ -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,7 +32,10 @@ import 'package:workmanager/workmanager.dart' hide TaskStatus;
|
|||
final lockRetransmission = Mutex();
|
||||
|
||||
Future<void> reuploadMediaFiles() async {
|
||||
return lockRetransmission.protect(() async {
|
||||
return exclusiveAccess(
|
||||
lockName: 'reupload_maintenance',
|
||||
mutex: lockRetransmission,
|
||||
action: () async {
|
||||
final receipts = await twonlyDB.receiptsDao
|
||||
.getReceiptsForMediaRetransmissions();
|
||||
|
||||
|
|
@ -148,7 +152,8 @@ Future<void> reuploadMediaFiles() async {
|
|||
await tryToSendCompleteMessage(receipt: receipt);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reuploadMediaFile(
|
||||
|
|
@ -187,7 +192,13 @@ Future<void> reuploadMediaFile(
|
|||
}
|
||||
}
|
||||
|
||||
final Mutex _lockPreprocessing = Mutex();
|
||||
|
||||
Future<void> finishStartedPreprocessing() async {
|
||||
return exclusiveAccess(
|
||||
lockName: 'preprocessing_maintenance',
|
||||
mutex: _lockPreprocessing,
|
||||
action: () async {
|
||||
final mediaFiles = await twonlyDB.mediaFilesDao
|
||||
.getAllMediaFilesPendingUpload();
|
||||
|
||||
|
|
@ -250,6 +261,8 @@ Future<void> finishStartedPreprocessing() async {
|
|||
Log.warn(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// It can happen, that a media files is uploaded but not yet marked for been uploaded.
|
||||
|
|
|
|||
|
|
@ -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<void> 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<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) {
|
||||
// Reload the users, as on Android the background isolate can
|
||||
// stay alive for multiple hours between task executions
|
||||
|
|
@ -63,7 +78,7 @@ Future<bool> initBackgroundExecution() async {
|
|||
return false;
|
||||
}
|
||||
|
||||
AppState.isInBackgroundTask = true;
|
||||
Log.info('Background task is initialized');
|
||||
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -33,39 +33,58 @@ class MediaFileService {
|
|||
);
|
||||
|
||||
final files = tempDirectory.listSync();
|
||||
if (files.isEmpty) return;
|
||||
|
||||
final mediaIdToFile = <String, List<FileSystemEntity>>{};
|
||||
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 = <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
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> initFCMService() async {
|
|||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
SentryWidgetsFlutterBinding.ensureInitialized();
|
||||
final isInitialized = await initBackgroundExecution();
|
||||
Log.info('Handling a background message: ${message.messageId}');
|
||||
await handleRemoteMessage(message);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ class Log {
|
|||
);
|
||||
}
|
||||
});
|
||||
cleanLogFile();
|
||||
}
|
||||
|
||||
static String filterLogMessage(String msg) {
|
||||
|
|
@ -106,7 +105,7 @@ Future<void> _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()) {
|
||||
|
|
|
|||
43
lib/src/utils/startup_guard.dart
Normal file
43
lib/src/utils/startup_guard.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VerificationBadgeComp> {
|
|||
.listen((update) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
Log.info('Update: ${update.length}');
|
||||
_isVerified = update.isNotEmpty;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ class ChatMessagesView extends StatefulWidget {
|
|||
class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||
with WidgetsBindingObserver {
|
||||
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
||||
late StreamSubscription<Group?> userSub;
|
||||
late StreamSubscription<List<Message>> messageSub;
|
||||
StreamSubscription<Group?>? userSub;
|
||||
StreamSubscription<List<Message>>? messageSub;
|
||||
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
||||
StreamSubscription<List<Contact>>? contactSub;
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
List<MemoryItem> 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<ChatMessagesView>
|
|||
|
||||
@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<ChatMessagesView>
|
|||
setState(() {
|
||||
quotesMessage = chatMessage;
|
||||
});
|
||||
textFieldFocus.requestFocus();
|
||||
textFieldFocus?.requestFocus();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -394,7 +395,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
MessageInput(
|
||||
group: group,
|
||||
quotesMessage: quotesMessage,
|
||||
textFieldFocus: textFieldFocus,
|
||||
textFieldFocus: textFieldFocus!,
|
||||
onMessageSend: () {
|
||||
setState(() {
|
||||
quotesMessage = null;
|
||||
|
|
|
|||
|
|
@ -43,9 +43,8 @@ class TypingIndicator extends StatefulWidget {
|
|||
class _TypingIndicatorState extends State<TypingIndicator> {
|
||||
List<GroupMember> _groupMembers = [];
|
||||
|
||||
late StreamSubscription<List<(Contact, GroupMember)>> membersSub;
|
||||
|
||||
late Timer _periodicUpdate;
|
||||
StreamSubscription<List<(Contact, GroupMember)>>? membersSub;
|
||||
Timer? _periodicUpdate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -73,8 +72,8 @@ class _TypingIndicatorState extends State<TypingIndicator> {
|
|||
|
||||
@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<AnimatedTypingDots>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
late List<Animation<double>> _animations;
|
||||
AnimationController? _controller;
|
||||
List<Animation<double>>? _animations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
|
|
@ -172,29 +171,30 @@ class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
|
|||
),
|
||||
]).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],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<LogViewerWidget> {
|
|||
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<LogViewerWidget> {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)?)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
if let Some(fg) = file_guard {
|
||||
TRACING_GUARDS
|
||||
.set(Mutex::new(Some((file_guard, stdout_guard))))
|
||||
.set(Mutex::new(Some((fg, stdout_guard))))
|
||||
.ok();
|
||||
}
|
||||
|
||||
(non_blocking_stdout, non_blocking_file)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Reference in a new issue