From dfa3648243b91b9f252c728baf636f4ba4b873d1 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:22:22 -0700 Subject: [PATCH] Round-trip CloudKit reaction parents safely --- lib/database/io/attachment.dart | 13 +- lib/database/io/message.dart | 32 ++++- lib/helpers/ui/reaction_helpers.dart | 14 +- ...d_associated_message_parent_reference.dart | 135 ++++++++++++++++++ lib/utils/attachment_guid_utils.dart | 53 +++++++ .../cloudkit_reaction_roundtrip_test.dart | 92 ++++++++++++ 6 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 lib/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart create mode 100644 lib/utils/attachment_guid_utils.dart create mode 100644 test/services/rustpush/cloudkit_reaction_roundtrip_test.dart diff --git a/lib/database/io/attachment.dart b/lib/database/io/attachment.dart index 057ca17166..d0ea4e827c 100644 --- a/lib/database/io/attachment.dart +++ b/lib/database/io/attachment.dart @@ -5,6 +5,7 @@ import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; +import 'package:bluebubbles/utils/attachment_guid_utils.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -64,11 +65,11 @@ class Attachment { totalBytes = decoded.totalBytes; metadata ??= {}; metadata!["cloud"] = ckRecordId; - if (decoded.guid.startsWith("at")) { - var items = decoded.guid.split("_"); + final owned = parseAppleOwnedAttachmentGuid(decoded.guid); + if (owned != null) { // format defined in indexedPartsToAttributedBodyDyn - var message = Message.findOne(guid: items[2]); - guid = "${items[2]}_${items[1]}"; + var message = Message.findOne(guid: owned.messageGuid); + guid = "${owned.messageGuid}_${owned.part}"; save(message); } else { guid = decoded.guid; @@ -77,9 +78,7 @@ class Attachment { } String unconvertAttachmentGuid(String guid) { - var items = guid.split("_"); - if (items.length == 1) return guid; - return "at_${items[1]}_${items[0]}"; + return unconvertAppleAttachmentGuid(guid); } Future getAttachmentMeta() async { diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..165d2de5e8 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -7,6 +7,7 @@ import 'package:async_task/async_task.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; +import 'package:bluebubbles/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; @@ -1070,7 +1071,10 @@ class Message { dateDelivered: dateDelivered != null ? RustPushBBUtils.nsSinceAppleEpoch(dateDelivered!) : 0, unk14: 0, associatedMessageType: amt, - associatedMessageGuid: associatedMessageGuid != null ? "p:$associatedMessagePart/$associatedMessageGuid" : null, + associatedMessageGuid: CloudAssociatedMessageParentReference.encode( + localMessageGuid: associatedMessageGuid, + part: associatedMessagePart, + ), associatedMessageRangeLength: associatedMessagePart != null ? Message.findOne(guid: associatedMessageGuid!)?.attributedBody[0].runs.firstWhere((r) => r.attributes!.messagePart == associatedMessagePart).range[1] : null, associatedMessageRangeLocation: associatedMessagePart != null ? Message.findOne(guid: associatedMessageGuid!)?.attributedBody[0].runs.firstWhere((r) => r.attributes!.messagePart == associatedMessagePart).range[0] : null )), @@ -1155,17 +1159,31 @@ class Message { expressiveSendStyleId = proto1.effect; dateRead = proto1.dateRead == null || proto1.dateRead == 0 ? null : RustPushBBUtils.fromNsSinceAppleEpoch(proto1.dateRead!); dateDelivered = proto1.dateDelivered == null || proto1.dateDelivered == 0 ? null : RustPushBBUtils.fromNsSinceAppleEpoch(proto1.dateDelivered!); + associatedMessageType = null; + associatedMessageGuid = null; + associatedMessagePart = null; if (proto1.associatedMessageType != null) { if (proto1.associatedMessageType == 2) { associatedMessageType = "sticker"; - } else if (proto1.associatedMessageType! >= 2000 && proto1.associatedMessageType! < 3000) { - associatedMessageType = ReactionTypes.toList()[proto1.associatedMessageType! - 2000]; - } else if (proto1.associatedMessageType! >= 3000 && proto1.associatedMessageType! < 4000) { - associatedMessageType = "-${ReactionTypes.toList()[proto1.associatedMessageType! - 3000]}"; + } else { + associatedMessageType = + ReactionTypes.fromAssociatedMessageType(proto1.associatedMessageType!); + } + + if (associatedMessageType != null && + proto1.associatedMessageGuid != null) { + try { + final parent = CloudAssociatedMessageParentReference.parse( + proto1.associatedMessageGuid!, + ); + associatedMessageGuid = parent.localMessageGuid; + associatedMessagePart = parent.part; + } on CloudAssociatedMessageParentReferenceFormatException { + // Keep the message, but do not attach a malformed reaction to a + // potentially unrelated local message. + } } } - associatedMessageGuid = proto1.associatedMessageGuid; - associatedMessagePart = 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/helpers/ui/reaction_helpers.dart b/lib/helpers/ui/reaction_helpers.dart index c30bcbf24a..3a15e84aab 100644 --- a/lib/helpers/ui/reaction_helpers.dart +++ b/lib/helpers/ui/reaction_helpers.dart @@ -20,6 +20,18 @@ class ReactionTypes { // ignore: non_constant_identifier_names static const String STICKERBACK = "stickerback"; + /// Maps Apple's associated-message type to a reaction name. + /// + /// Unknown indices are not reactions this build can render and return null + /// instead of indexing [toList] and throwing a RangeError. + static String? fromAssociatedMessageType(int associatedMessageType) { + final removed = associatedMessageType >= 3000; + final index = associatedMessageType - (removed ? 3000 : 2000); + final names = toList(); + if (index < 0 || index >= names.length) return null; + return removed ? "-${names[index]}" : names[index]; + } + static List toList() { return [ LOVE, @@ -93,4 +105,4 @@ List getUniqueReactionMessages(List messages) { } return output; -} \ No newline at end of file +} diff --git a/lib/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart b/lib/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart new file mode 100644 index 0000000000..852db54db0 --- /dev/null +++ b/lib/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart @@ -0,0 +1,135 @@ +/// A validated CloudKit reference to the local parent of an associated message. +/// +/// Apple uses `p:/` for a specific message part, +/// `bp:/` for a bubble or tapback message, and a bare +/// `` when the reaction targets the whole message. The raw value +/// is accepted only at this parsing boundary and is never retained in errors. +final class CloudAssociatedMessageParentReference { + const CloudAssociatedMessageParentReference._({ + required this.part, + required this.localMessageGuid, + }); + + static const int maximumGuidCodeUnits = 512; + static const int _maximumPartDigits = 19; + + /// Null when Apple sent a bare GUID for a whole-message reaction. + final int? part; + final String localMessageGuid; + + /// Encodes the legacy CloudKit parent form used by uploads. + static String? encode({String? localMessageGuid, int? part}) { + if (localMessageGuid == null) return null; + return part == null ? localMessageGuid : 'p:$part/$localMessageGuid'; + } + + static CloudAssociatedMessageParentReference parse(String encoded) { + const prefix = 'p:'; + const bubblePrefix = 'bp:'; + const separatorLength = 1; + const maximumEncodedCodeUnits = + bubblePrefix.length + + _maximumPartDigits + + separatorLength + + maximumGuidCodeUnits; + if (encoded.length > maximumEncodedCodeUnits) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + + final matchedPrefix = encoded.startsWith(prefix) + ? prefix + : (encoded.startsWith(bubblePrefix) ? bubblePrefix : null); + if (matchedPrefix == null) { + if (encoded.contains('/') || + encoded.contains(':') || + !_isValidGuid(encoded)) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + return CloudAssociatedMessageParentReference._( + part: null, + localMessageGuid: encoded, + ); + } + + final separator = encoded.indexOf('/', matchedPrefix.length); + if (separator == -1 || + encoded.indexOf('/', separator + 1) != -1 || + separator == matchedPrefix.length) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + + final encodedPart = encoded.substring(matchedPrefix.length, separator); + if (encodedPart.length > _maximumPartDigits || + !_isAsciiDecimal(encodedPart) || + (encodedPart.length > 1 && encodedPart.startsWith('0'))) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + final part = int.tryParse(encodedPart); + if (part == null || part < 0) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + + final guid = encoded.substring(separator + 1); + if (!_isValidGuid(guid)) { + throw const CloudAssociatedMessageParentReferenceFormatException(); + } + + return CloudAssociatedMessageParentReference._( + part: part, + localMessageGuid: guid, + ); + } + + static bool _isAsciiDecimal(String value) { + for (final codeUnit in value.codeUnits) { + if (codeUnit < 0x30 || codeUnit > 0x39) return false; + } + return value.isNotEmpty; + } + + static bool _isValidGuid(String value) { + if (value.isEmpty || value.length > maximumGuidCodeUnits) return false; + for (final codeUnit in value.codeUnits) { + if (codeUnit <= 0x20 || + (codeUnit >= 0x7f && codeUnit <= 0x9f) || + codeUnit == 0x2028 || + codeUnit == 0x2029) { + return false; + } + } + return true; + } + + @override + bool operator ==(Object other) => + other is CloudAssociatedMessageParentReference && + other.part == part && + other.localMessageGuid == localMessageGuid; + + @override + int get hashCode => Object.hash(part, localMessageGuid); + + @override + String toString() => 'CloudAssociatedMessageParentReference(redacted)'; +} + +/// A deliberately redacted parse failure. +final class CloudAssociatedMessageParentReferenceFormatException + implements FormatException { + const CloudAssociatedMessageParentReferenceFormatException(); + + static const String safeCode = 'invalid_associated_message_parent_reference'; + + @override + String get message => safeCode; + + @override + int? get offset => null; + + @override + Object? get source => null; + + @override + String toString() => + 'CloudAssociatedMessageParentReferenceFormatException($safeCode)'; +} diff --git a/lib/utils/attachment_guid_utils.dart b/lib/utils/attachment_guid_utils.dart new file mode 100644 index 0000000000..043ad0232d --- /dev/null +++ b/lib/utils/attachment_guid_utils.dart @@ -0,0 +1,53 @@ +// Apple represents an attachment owned by a message as `at__`, and OpenBubbles stores the same identity locally as +// `_`. +// +// A message GUID can contain underscores, so conversion must split only at the +// separator adjacent to the part number. Invalid identifiers are returned +// unchanged because they are parsed on the CloudKit download path. + +class AppleOwnedAttachmentGuid { + const AppleOwnedAttachmentGuid({ + required this.part, + required this.messageGuid, + }); + + final String part; + final String messageGuid; +} + +AppleOwnedAttachmentGuid? parseAppleOwnedAttachmentGuid(String guid) { + const prefix = 'at_'; + if (!guid.startsWith(prefix)) return null; + final remainder = guid.substring(prefix.length); + final separator = remainder.indexOf('_'); + if (separator <= 0 || separator >= remainder.length - 1) return null; + final part = remainder.substring(0, separator); + if (!_isCanonicalDecimal(part)) return null; + return AppleOwnedAttachmentGuid( + part: part, + messageGuid: remainder.substring(separator + 1), + ); +} + +bool _isCanonicalDecimal(String value) { + if (value.isEmpty) return false; + for (final unit in value.codeUnits) { + if (unit < 0x30 || unit > 0x39) return false; + } + return value == '0' || value.codeUnitAt(0) != 0x30; +} + +String convertAppleAttachmentGuid(String guid) { + final owned = parseAppleOwnedAttachmentGuid(guid); + if (owned == null) return guid; + return '${owned.messageGuid}_${owned.part}'; +} + +String unconvertAppleAttachmentGuid(String guid) { + final separator = guid.lastIndexOf('_'); + if (separator <= 0 || separator >= guid.length - 1) return guid; + final part = guid.substring(separator + 1); + if (!_isCanonicalDecimal(part)) return guid; + return 'at_${part}_${guid.substring(0, separator)}'; +} diff --git a/test/services/rustpush/cloudkit_reaction_roundtrip_test.dart b/test/services/rustpush/cloudkit_reaction_roundtrip_test.dart new file mode 100644 index 0000000000..613969ad60 --- /dev/null +++ b/test/services/rustpush/cloudkit_reaction_roundtrip_test.dart @@ -0,0 +1,92 @@ +import 'package:bluebubbles/helpers/ui/reaction_helpers.dart'; +import 'package:bluebubbles/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart'; +import 'package:bluebubbles/utils/attachment_guid_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const guid = 'ABCDEF12-3456-7890-ABCD-EF1234567890'; + + test('accepts bare, p, and bp CloudKit parent forms', () { + final bare = CloudAssociatedMessageParentReference.parse(guid); + final part = CloudAssociatedMessageParentReference.parse('p:3/$guid'); + final bubble = CloudAssociatedMessageParentReference.parse('bp:4/$guid'); + + expect(bare.localMessageGuid, guid); + expect(bare.part, isNull); + expect(part.localMessageGuid, guid); + expect(part.part, 3); + expect(bubble.localMessageGuid, guid); + expect(bubble.part, 4); + }); + + test('encodes partless parents without a p:null wrapper', () { + expect( + CloudAssociatedMessageParentReference.encode( + localMessageGuid: guid, + part: null, + ), + guid, + ); + expect( + CloudAssociatedMessageParentReference.encode( + localMessageGuid: guid, + part: 0, + ), + 'p:0/$guid', + ); + final decoded = CloudAssociatedMessageParentReference.parse( + CloudAssociatedMessageParentReference.encode( + localMessageGuid: guid, + part: null, + )!, + ); + expect(decoded.localMessageGuid, guid); + expect(decoded.part, isNull); + }); + + test('rejects malformed parent forms without exposing the input', () { + for (final value in [ + '', + '0/$guid', + 'bpdi:0/$guid', + 'p:01/$guid', + 'p:x/$guid', + 'p:0/$guid/extra', + ]) { + Object? caught; + try { + CloudAssociatedMessageParentReference.parse(value); + } catch (error) { + caught = error; + } + + expect(caught, + isA()); + if (value.isNotEmpty) { + expect(caught.toString(), isNot(contains(value))); + } + } + }); + + test('maps known reaction types and ignores newer unknown types', () { + expect(ReactionTypes.fromAssociatedMessageType(2000), 'love'); + expect(ReactionTypes.fromAssociatedMessageType(3001), '-like'); + expect(ReactionTypes.fromAssociatedMessageType(2008), isNull); + expect(ReactionTypes.fromAssociatedMessageType(4000), isNull); + }); + + test('parses owned attachment GUIDs without losing underscores', () { + final parsed = parseAppleOwnedAttachmentGuid('at_12/message_guid_42'); + expect(parsed, isNull); + + final owned = parseAppleOwnedAttachmentGuid('at_12_message_guid_42'); + expect(owned!.part, '12'); + expect(owned.messageGuid, 'message_guid_42'); + expect(convertAppleAttachmentGuid('at_12_message_guid_42'), + 'message_guid_42_12'); + expect(unconvertAppleAttachmentGuid('message_guid_42_12'), + 'at_12_message_guid_42'); + expect(convertAppleAttachmentGuid('at_01_message_guid'), + 'at_01_message_guid'); + }); +}