From 9233f75ad5c2e836917819dacc766ce5edff4b0d Mon Sep 17 00:00:00 2001 From: Joel Jothiprakasam Date: Fri, 14 Aug 2026 10:18:49 -0700 Subject: [PATCH 1/4] Potential fix to freezing on startup Signed-off-by: Joel Jothiprakasam --- .../sync/incremental_sync_manager.dart | 13 +++--- lib/services/backend/sync/sync_service.dart | 45 ++++++++++++------- lib/services/isolates/isolate_event.dart | 2 +- lib/services/ui/chat/chats_service.dart | 7 ++- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/services/backend/sync/incremental_sync_manager.dart b/lib/services/backend/sync/incremental_sync_manager.dart index 4194ade5ae..c005e6c418 100644 --- a/lib/services/backend/sync/incremental_sync_manager.dart +++ b/lib/services/backend/sync/incremental_sync_manager.dart @@ -364,7 +364,7 @@ class IncrementalSyncManager extends SyncManager { syncedChats.addAll(chatCache); // For each chat, bulk sync the messages - final pageMessageIds = []; + final pageMessageIdsByChat = >{}; final pageLatestMessageIdPerChat = {}; for (var item in messagesToSync.entries) { @@ -387,9 +387,10 @@ class IncrementalSyncManager extends SyncManager { latestMessageIdPerChat[item.key] = latest.id!; } - // Collect per-page data for the progressive UI update event. - for (final m in syncResult.messages) { - if (m.id != null) pageMessageIds.add(m.id!); + // Grouped by chat so the main thread can skip hydrating messages for closed chats. + final ids = syncResult.messages.where((m) => m.id != null).map((m) => m.id!).toList(); + if (ids.isNotEmpty) { + pageMessageIdsByChat[item.key] = ids; } if (latest != null) { pageLatestMessageIdPerChat[item.key] = latest.id!; @@ -398,9 +399,9 @@ class IncrementalSyncManager extends SyncManager { // Emit a per-page event so the main thread can update the UI incrementally // without waiting for all pages to finish. - if (isIsolate && pageMessageIds.isNotEmpty) { + if (isIsolate && pageMessageIdsByChat.isNotEmpty) { IsolateEventEmitter.emit(IsolateEvent.incrementalSyncPageComplete, { - 'messageIds': pageMessageIds, + 'messageIdsByChat': pageMessageIdsByChat, 'latestMessageIdPerChat': pageLatestMessageIdPerChat, }); } diff --git a/lib/services/backend/sync/sync_service.dart b/lib/services/backend/sync/sync_service.dart index 6210662315..fbec836e7a 100644 --- a/lib/services/backend/sync/sync_service.dart +++ b/lib/services/backend/sync/sync_service.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/ui/async_task.dart'; import 'package:bluebubbles/helpers/ui/ui_helpers.dart'; import 'package:bluebubbles/services/backend/interfaces/contact_v2_interface.dart'; import 'package:bluebubbles/services/backend/interfaces/sync_interface.dart'; @@ -24,6 +26,7 @@ class SyncService { final RxBool isIncrementalSyncing = false.obs; static const Duration _incrementalSyncCooldown = Duration(seconds: 30); + static const int _dispatchChunkSize = 50; DateTime? _lastIncrementalSyncTimestamp; FullSyncManager? _manager; @@ -73,25 +76,17 @@ class SyncService { final processedMessageIds = {}; final processedSubtitleByChat = {}; // chatGuid → message DB ID + // This runs on the UI thread (isolate event listeners are invoked synchronously), + // so a page of up to `batchSize` messages must never be hydrated in one block. Future onPageComplete(dynamic data) async { if (data is! Map) return; - final messageIds = (data['messageIds'] as List).cast(); + final messageIdsByChat = + (data['messageIdsByChat'] as Map).map((k, v) => MapEntry(k as String, (v as List).cast())); final latestPerChat = Map.from(data['latestMessageIdPerChat'] as Map); - // Hydrate the page's messages and dispatch to any open chat view immediately. - final messages = Database.messages.getMany(messageIds).whereType().toList(); - for (final message in messages) { - if (message.id != null) processedMessageIds.add(message.id!); - final chatGuid = message.chat.target?.guid; - if (chatGuid == null || message.guid == null) continue; - if (Get.isRegistered(tag: chatGuid)) { - unawaited(Get.find(tag: chatGuid).addNewMessage(message)); - } - } - - // Update chat subtitles for the per-page latest message per chat. + // repositionImmediate: false so a page touching many chats rebuilds the list once. for (final entry in latestPerChat.entries) { - final msg = Database.messages.get(entry.value); + final msg = await runAsync(() => Database.messages.get(entry.value)); if (msg == null) continue; // If this chat was created for the first time during this sync, // ChatState doesn't exist yet — register it so updateChatLatestMessage @@ -100,9 +95,27 @@ class SyncService { final chat = msg.chat.target; if (chat != null) await ChatsSvc.addChat(chat, immediate: true); } - ChatsSvc.updateChatLatestMessage(entry.key, msg); + ChatsSvc.updateChatLatestMessage(entry.key, msg, repositionImmediate: false); processedSubtitleByChat[entry.key] = entry.value; } + + // Only an open conversation view needs the individual messages — every other + // chat is served by the subtitle update above. + for (final entry in messageIdsByChat.entries) { + if (maybeFindMessagesSvc(entry.key) == null) continue; + for (int i = 0; i < entry.value.length; i += _dispatchChunkSize) { + // Re-resolve every chunk: the user can leave this chat between chunks + final service = maybeFindMessagesSvc(entry.key); + if (service == null) break; + final chunk = entry.value.sublist(i, math.min(i + _dispatchChunkSize, entry.value.length)); + final messages = await runAsync(() => Database.messages.getMany(chunk).whereType().toList()); + for (final message in messages) { + if (message.guid == null) continue; + await service.addNewMessage(message); + if (message.id != null) processedMessageIds.add(message.id!); + } + } + } } final syncIsolate = GetIt.I(); @@ -134,7 +147,7 @@ class SyncService { for (final entry in latestPerChat.entries) { final message = entry.value; if (message.id != null && processedSubtitleByChat[entry.key] == message.id) continue; - ChatsSvc.updateChatLatestMessage(entry.key, message); + ChatsSvc.updateChatLatestMessage(entry.key, message, repositionImmediate: false); } // Dispatch newly synced messages to any currently active chat view. diff --git a/lib/services/isolates/isolate_event.dart b/lib/services/isolates/isolate_event.dart index d40d01b008..afdc85a450 100644 --- a/lib/services/isolates/isolate_event.dart +++ b/lib/services/isolates/isolate_event.dart @@ -34,7 +34,7 @@ enum IsolateEvent { /// Emitted after each page of messages is persisted during incremental sync. /// Payload: `Map` with keys: - /// `messageIds` — `List` of all DB IDs saved in this page + /// `messageIdsByChat` — `Map>` chatGuid → DB IDs saved in this page /// `latestMessageIdPerChat` — `Map` chatGuid → latest message DB ID in this page incrementalSyncPageComplete, diff --git a/lib/services/ui/chat/chats_service.dart b/lib/services/ui/chat/chats_service.dart index c4240b1a63..abacaf1afb 100644 --- a/lib/services/ui/chat/chats_service.dart +++ b/lib/services/ui/chat/chats_service.dart @@ -1585,7 +1585,10 @@ class ChatsService { /// older delta message as a chat's latest, which would rewind its sort order. /// The 2s tolerance allows a temp->real GUID swap. [allowOlder] opts out for the /// post-deletion recompute, which must fall back to an older surviving message. - void updateChatLatestMessage(String chatGuid, Message message, {bool allowOlder = false}) { + /// [repositionImmediate] controls the chat list rebuild only — the ChatState update is always + /// synchronous. Pass false when updating many chats in a row so the rebuilds coalesce. + void updateChatLatestMessage(String chatGuid, Message message, + {bool allowOlder = false, bool repositionImmediate = true}) { final state = getChatState(chatGuid); if (state == null) return; @@ -1611,7 +1614,7 @@ class ChatsService { state.updateSubtitleInternal( message.getNotificationText(hideContactInfo: hideContactInfo, hideMessageContent: hideMessageContent)); state.chat.setLatestMessage(message); - _repositionChat(state.chat, immediate: true); + _repositionChat(state.chat, immediate: repositionImmediate); } /// Set chat text field text From 33ac8b79e1ebe8d8030ad859a2d6bb718149ed00 Mon Sep 17 00:00:00 2001 From: Joel Jothiprakasam Date: Fri, 14 Aug 2026 12:32:52 -0700 Subject: [PATCH 2/4] Potential actual fix for cross-device issue Signed-off-by: Joel Jothiprakasam --- lib/database/database.dart | 4 +- .../desktop_shared_preferences_store.dart | 57 +++++++++---------- lib/utils/file_utils.dart | 24 +++----- 3 files changed, 38 insertions(+), 47 deletions(-) diff --git a/lib/database/database.dart b/lib/database/database.dart index 070c257332..033da30654 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:bluebubbles/env.dart'; import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -75,7 +76,8 @@ class Database { Logger.info( "Database init: SettingsSvc.finishedSetup = $setupFinished, PrefsSvc.finishedSetup = $setupFinished2"); - if (!setupFinished) { + // Setup only ever runs on main, so only main may act on an unfinished one. + if (!setupFinished && !isIsolate) { Logger.warn("Clearing database because setup is not finished..."); Database.attachments.removeAll(); diff --git a/lib/services/backend/settings/desktop_shared_preferences_store.dart b/lib/services/backend/settings/desktop_shared_preferences_store.dart index 77c0d2881b..fbe1e9ebc9 100644 --- a/lib/services/backend/settings/desktop_shared_preferences_store.dart +++ b/lib/services/backend/settings/desktop_shared_preferences_store.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:bluebubbles/services/backend/filesystem/filesystem_service.dart'; import 'package:bluebubbles/utils/file_utils.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:path/path.dart' as p; -import 'package:path_provider_linux/path_provider_linux.dart'; -import 'package:path_provider_windows/path_provider_windows.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shared_preferences_platform_interface/types.dart'; @@ -29,15 +28,18 @@ import 'package:shared_preferences_platform_interface/types.dart'; /// - Writes are serialized across isolates AND processes with an exclusively /// created lock file. (`RandomAccessFile.lock` is not enough: POSIX fcntl /// locks are process-owned and do not exclude isolates within one process.) -/// - Writes are atomic: temp file + rename, so the store can never be -/// truncated by a crash mid-write. +/// - Writes are atomic where the filesystem allows it: temp file + rename, so +/// the store can never be truncated by a crash mid-write. Where the rename +/// can't replace the file, it falls back to writing in place and the `.bak` +/// below covers it. /// - Every successful write also refreshes a `.bak` copy (under the same /// lock), and an unparseable file is quarantined and restored from that /// backup — at registration and on mid-session reads — instead of crashing /// the app or losing all settings. /// -/// Storage location and format are identical to the stock implementation, so -/// existing user data carries over untouched. Custom file names via +/// Format is identical to the stock implementation, and so is the location +/// except on MSIX, where [FilesystemService] has already migrated the whole +/// directory to the real path behind the AppData redirect. Custom file names via /// platform-specific [SharedPreferencesOptions] subclasses are not supported; /// the app only ever uses the defaults. base class DesktopSharedPreferencesStore extends SharedPreferencesAsyncPlatform { @@ -49,7 +51,6 @@ base class DesktopSharedPreferencesStore extends SharedPreferencesAsyncPlatform static const Duration _staleLockTimeout = Duration(seconds: 10); static const Duration _lockRetryDelay = Duration(milliseconds: 5); - String? _cachedDirectoryPath; Future _writeQueue = Future.value(); /// Registers this store as the [SharedPreferencesAsyncPlatform] and @@ -133,31 +134,21 @@ base class DesktopSharedPreferencesStore extends SharedPreferencesAsyncPlatform Future> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) async => (await getPreferences(parameters, options)).keys.toSet(); - Future _getDirectoryPath() async { - if (_cachedDirectoryPath != null) return _cachedDirectoryPath!; - // Instantiated directly (instead of going through path_provider) so this - // works in background isolates without plugin registration, exactly like - // the stock implementations do. - final String? directory = Platform.isWindows - ? await PathProviderWindows().getApplicationSupportPath() - : await PathProviderLinux().getApplicationSupportPath(); - if (directory == null) { - throw const FileSystemException('Unable to resolve the application support directory for preferences'); - } - return _cachedDirectoryPath = directory; - } - - Future _getDataFile() async => File(p.join(await _getDirectoryPath(), _fileName)); + // Not path_provider's support path directly: on MSIX that one is the AppData + // redirect, where a same-folder rename can fail as cross-device. Every + // isolate's init registers FilesystemService before this store. + Future _getDataFile() async => File(p.join(FilesystemSvc.appDocDir.path, _fileName)); Future _getBackupFile() async => File('${(await _getDataFile()).path}$_backupSuffix'); - /// Returns the parsed contents of [file], `{}` for an existing-but-empty - /// file, or null when the file is missing or unparseable. + /// Returns the parsed contents of [file], or null when it is missing, empty + /// or unparseable. Empty means a reader caught an in-place write mid-truncate, + /// never "no preferences set" — that is stored as `{}`. Map? _parseFile(File file) { try { if (!file.existsSync()) return null; final String contents = file.readAsStringSync(); - if (contents.isEmpty) return {}; + if (contents.isEmpty) return null; final Object? decoded = json.decode(contents); return decoded is Map ? decoded.cast() : null; } on FormatException catch (e) { @@ -239,20 +230,26 @@ base class DesktopSharedPreferencesStore extends SharedPreferencesAsyncPlatform Future _atomicWrite(Map prefs) async { final File file = await _getDataFile(); final File tmp = File('${file.path}.tmp'); + final String contents = json.encode(prefs); try { final RandomAccessFile raf = tmp.openSync(mode: FileMode.write); try { - raf.writeStringSync(json.encode(prefs)); + raf.writeStringSync(contents); raf.flushSync(); } finally { raf.closeSync(); } - // Not atomic if it has to fall back to a copy — the `.bak` refreshed - // below and the corrupt-file recovery are what cover that. await moveFile(tmp, file.path); } on FileSystemException catch (e) { - _log('Failed to save preferences: $e'); - return; + // Neither rename nor copy can replace a file another handle holds open + // (`File.copy` deletes the destination first), but an in-place write can. + _log('Atomic preferences write failed, writing in place: $e'); + try { + file.writeAsStringSync(contents, flush: true); + } on FileSystemException catch (e) { + _log('Failed to save preferences: $e'); + return; + } } try { file.copySync((await _getBackupFile()).path); diff --git a/lib/utils/file_utils.dart b/lib/utils/file_utils.dart index 09b0362e6a..8cc0c9dd85 100644 --- a/lib/utils/file_utils.dart +++ b/lib/utils/file_utils.dart @@ -28,17 +28,14 @@ Future revealInFileManager(String path) async { } /// Moves [source] onto [targetPath], by rename where the filesystem allows it. -Future moveFile(File source, String targetPath, {int renameAttempts = 5}) async { - for (int attempt = 1; attempt <= renameAttempts; attempt++) { - try { - await source.rename(targetPath); - return; - } on PathNotFoundException { - rethrow; - } on FileSystemException catch (e) { - if (isCrossDeviceError(e) || attempt >= renameAttempts) break; - await Future.delayed(const Duration(milliseconds: 10)); - } +Future moveFile(File source, String targetPath) async { + try { + await source.rename(targetPath); + return; + } on PathNotFoundException { + rethrow; + } on FileSystemException { + // Cross-device, or the destination can't be replaced. } await source.copy(targetPath); try { @@ -48,11 +45,6 @@ Future moveFile(File source, String targetPath, {int renameAttempts = 5}) } } -/// Win32 ERROR_NOT_SAME_DEVICE (17) on Windows, POSIX EXDEV (18) everywhere -/// else — Dart reports Win32 codes there and errno elsewhere, so the two can't -/// be checked together: 17 is EEXIST on POSIX. -bool isCrossDeviceError(FileSystemException e) => e.osError?.errorCode == (Platform.isWindows ? 17 : 18); - /// Desktop "Save As": asks where to put the file, then puts it there. Returns /// the path it was saved to, or null if the user cancelled the dialog. Future saveFileAs({ From ad0e5771563bdcef14ca59c995277e9efb04673d Mon Sep 17 00:00:00 2001 From: Joel Jothiprakasam Date: Tue, 22 Sep 2026 19:51:50 -0500 Subject: [PATCH 3/4] Fix freezing... Signed-off-by: Joel Jothiprakasam --- windows/runner/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index a8bf4ca7ec..129b9d2186 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -28,7 +28,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); -// project.set_ui_thread_policy(flutter::UIThreadPolicy::RunOnSeparateThread); + project.set_ui_thread_policy(flutter::UIThreadPolicy::RunOnSeparateThread); std::vector command_line_arguments = GetCommandLineArguments(); From 073eedc095a5d436616d0380de0a3fd612a2990a Mon Sep 17 00:00:00 2001 From: Joel Jothiprakasam Date: Wed, 23 Sep 2026 18:33:53 -0500 Subject: [PATCH 4/4] Unmerge Linux UI thread Signed-off-by: Joel Jothiprakasam --- linux/my_application.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/my_application.cc b/linux/my_application.cc index 13ff267012..e76e8366bc 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -106,6 +106,7 @@ static void my_application_activate(GApplication* application) { g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + fl_dart_project_set_ui_thread_policy(project, FL_UI_THREAD_POLICY_RUN_ON_SEPARATE_THREAD); FlView* view = fl_view_new(project);