From 7ff0bddabae0f0f2dfcd4256f52065831e285065 Mon Sep 17 00:00:00 2001 From: Frank Stack <294698533+FrankBStack@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:35:46 -0400 Subject: [PATCH] Fix reactions and DM messages lost during iCloud sync Reactions pulled from iCloud carry Apple's "p:/" form in associatedMessageGuid, but locally reactions are linked by the bare guid, so restored tapbacks never attached to their message. Strip the prefix on sync and use the part number from it when present. Cloud sync also routes DM messages by chatIdentifier, which chats created live in the app never set. Set it at creation time, and when a synced message still doesn't resolve, fall back to matching a DM by participant address and then to the group id in proto4 before giving up. A one-time repair on startup rewrites any already-stored prefixed reaction guids, backfills chatIdentifier on existing DMs, and clears the sync checkpoints so previously skipped messages get picked up on the next pass. --- lib/database/database.dart | 51 +++++++++++++++++++++ lib/database/io/message.dart | 45 ++++++++++++++++-- lib/services/rustpush/rustpush_service.dart | 2 + 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/lib/database/database.dart b/lib/database/database.dart index 15ad7e7e2f..0f80948cec 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -86,6 +86,57 @@ class Database { Logger.error("Failed to perform database migrations!", error: e, trace: s); } + try { + if (ss.prefs.getBool('cloudReactionRepair-1') != true) { + // Repair reactions restored from iCloud that kept Apple's "p:/" + // prefix in associatedMessageGuid — locally reactions link by the bare guid + final query = Database.messages.query(Message_.associatedMessageGuid.contains("/")).build(); + final broken = query.find(); + query.close(); + for (Message m in broken) { + final amg = m.associatedMessageGuid!; + final prefix = amg.substring(0, amg.indexOf("/")); + m.associatedMessageGuid = amg.substring(amg.indexOf("/") + 1); + if (prefix.startsWith("p:")) { + m.associatedMessagePart ??= int.tryParse(prefix.substring(2)); + } + } + if (broken.isNotEmpty) Database.messages.putMany(broken); + // Cloud restore used to drop messages whose chat hadn't synced yet while still + // advancing the checkpoint; reset the checkpoints so the next sync re-pages + // from the start and picks those messages up + await ss.prefs.remove("chatSyncToken"); + await ss.prefs.remove("attachmentSyncToken"); + await ss.prefs.remove("messageSyncToken"); + await ss.prefs.setBool('cloudReactionRepair-1', true); + Logger.info("Repaired ${broken.length} cloud-restored reactions and reset cloud sync checkpoints"); + } + } catch (e, s) { + Logger.error("Failed to repair cloud-restored reactions!", error: e, trace: s); + } + + try { + if (ss.prefs.getBool('cloudChatIdentifierRepair-1') != true) { + // Live-created chats never set chatIdentifier, which cloud sync uses to route + // DM messages — backfill it so the next sync can attach orphaned messages + final allChats = Database.chats.getAll(); + final List repaired = []; + for (Chat c in allChats) { + if (c.chatIdentifier == null && c.handles.length == 1) { + c.chatIdentifier = c.handles.first.address; + repaired.add(c); + } + } + if (repaired.isNotEmpty) Database.chats.putMany(repaired); + // re-page messages so previously-orphaned ones get attached + await ss.prefs.remove("messageSyncToken"); + await ss.prefs.setBool('cloudChatIdentifierRepair-1', true); + Logger.info("Backfilled chatIdentifier on ${repaired.length} chats and reset the message sync checkpoint"); + } + } catch (e, s) { + Logger.error("Failed to backfill chat identifiers!", error: e, trace: s); + } + initComplete.complete(); } diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..077cf826bf 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -1112,7 +1112,35 @@ class Message { chat ??= Chat.findByRustGuid(c.chatId); } - if (chat?.isRpSms ?? true) return; + final chatIdParts = c.chatId.split(";"); + if (chat == null && chatIdParts.length >= 3) { + // older chats may lack chatIdentifier, so fall back to matching a DM by participant address + final ident = chatIdParts[2]; + if (ident.isNotEmpty && ident != "null") { + final query = (Database.chats.query(Chat_.dateDeleted.isNull()) + ..linkMany(Chat_.handles, Handle_.address.equals(ident))) + .build(); + final results = query.find(); + query.close(); + chat = results.firstWhereOrNull((ch) => !ch.isGroup); + } + } + if (chat == null) { + // last resort: route by the original chat guid carried in proto4 + try { + if (c.msgProto4 != null) { + final proto4 = api.decodeMessageproto4(wrapped: c.msgProto4!); + if (proto4.groupId != null) { + chat = Chat.findByRustGuid(proto4.groupId!); + } + } + } catch (_) {} + } + if (chat == null) { + Logger.warn("Cloud message ${c.guid} references unknown chat ${c.chatId}; skipping"); + return; + } + if (chat.isRpSms) return; Logger.info("Syncing new message"); @@ -1164,8 +1192,19 @@ class Message { associatedMessageType = "-${ReactionTypes.toList()[proto1.associatedMessageType! - 3000]}"; } } - associatedMessageGuid = proto1.associatedMessageGuid; - associatedMessagePart = attributedBody.firstOrNull?.runs.firstWhereOrNull((b) => b.range[0] == proto1.associatedMessageRangeLocation && b.range[1] == proto1.associatedMessageRangeLength)?.attributes?.messagePart; + // the cloud stores the target in Apple's prefixed form ("p:/"), + // but locally reactions are linked by the bare guid + var amg = proto1.associatedMessageGuid; + int? amgPart; + if (amg != null && amg.contains("/")) { + var prefix = amg.substring(0, amg.indexOf("/")); + amg = amg.substring(amg.indexOf("/") + 1); + if (prefix.startsWith("p:")) { + amgPart = int.tryParse(prefix.substring(2)); + } + } + associatedMessageGuid = amg; + associatedMessagePart = amgPart ?? attributedBody.firstOrNull?.runs.firstWhereOrNull((b) => b.range[0] == proto1.associatedMessageRangeLocation && b.range[1] == proto1.associatedMessageRangeLength)?.attributes?.messagePart; guid = c.guid; var bits = c.flags.bits(); isFromMe = (bits & IS_FROM_ME) != 0; diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..ffea7fbb4d 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -432,6 +432,8 @@ class RustPushBackend implements BackendService { var formattedHandles = addresses.map((e) => RustPushBBUtils.rustHandleToBB(e)).toList(); var chat = Chat( guid: existingGuid ?? uuid.v4(), + // cloud sync routes DM messages by chatIdentifier; leaving it null orphans them + chatIdentifier: formattedHandles.length == 1 ? formattedHandles[0].address : null, participants: formattedHandles, usingHandle: handle, isRpSms: service == "SMS",