improve startup

This commit is contained in:
otsmr 2026-05-01 23:37:29 +02:00
parent 281014133a
commit f553713ff8
13 changed files with 294 additions and 208 deletions

View file

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

View file

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

View file

@ -53,6 +53,7 @@ Future<void> twonlyMinimumInitialization() async {
}
void main() async {
final stopwatch = Stopwatch()..start();
await twonlyMinimumInitialization();
unawaited(initFCMService());
@ -67,12 +68,9 @@ 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;
@ -81,7 +79,7 @@ void main() async {
final settingsController = SettingsChangeProvider()..loadSettings();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await initFileDownloader();
unawaited(initFileDownloader());
if (userExists) {
if (userService.currentUser.allowErrorTrackingViaSentry) {
@ -96,23 +94,16 @@ 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());
unawaited(postStartupTasks());
}
await apiService.listenToNetworkChanges();
unawaited(apiService.connect());
stopwatch.stop();
Log.info('Initialization finished after ${stopwatch.elapsed}.');
runApp(
MultiProvider(
providers: [
@ -161,3 +152,21 @@ Future<void> runMigrations() async {
});
}
}
Future<void> postStartupTasks() async {
// 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());
unawaited(initializeBackgroundTaskManager());
// 3. Delayed tasks (Wait for app to settle)
await Future.delayed(const Duration(minutes: 2));
unawaited(performTwonlySafeBackup());
}

View file

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

View file

@ -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,

View file

@ -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(

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/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.

View file

@ -31,6 +31,7 @@ Future<void> initializeBackgroundTaskManager() async {
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
AppState.isInBackgroundTask = true;
switch (task) {
case 'eu.twonly.periodic_task':
if (await initBackgroundExecution()) {
@ -63,8 +64,6 @@ Future<bool> initBackgroundExecution() async {
return false;
}
AppState.isInBackgroundTask = true;
_isInitialized = true;
return true;
}

View file

@ -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');
}
}

View file

@ -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;
});
});

View file

@ -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].split('.')[0]} ',
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,17 +289,18 @@ 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;
@ -318,6 +334,8 @@ class _LogEntry {
}
}
final isBackground = msg.contains('[b] ');
msg = msg
.trim()
.replaceAll('[twonly] ', '')
@ -326,8 +344,7 @@ class _LogEntry {
final fileNameS = msg.split(' > ');
final fileName = fileNameS[0];
msg = fileNameS.sublist(1).join();
msg = fileNameS.sublist(1).join(' > ');
return _LogEntry(
timestamp: ts,
@ -335,11 +352,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;
}

View file

@ -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.3+112
environment:
sdk: ^3.11.0

View file

@ -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)?)
}