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