Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions lib/database/io/attachment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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<api.AttachmentMeta> getAttachmentMeta() async {
Expand Down
32 changes: 25 additions & 7 deletions lib/database/io/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
)),
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion lib/helpers/ui/reaction_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> toList() {
return [
LOVE,
Expand Down Expand Up @@ -93,4 +105,4 @@ List<Message> getUniqueReactionMessages(List<Message> messages) {
}

return output;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/// A validated CloudKit reference to the local parent of an associated message.
///
/// Apple uses `p:<part>/<message-guid>` for a specific message part,
/// `bp:<part>/<message-guid>` for a bubble or tapback message, and a bare
/// `<message-guid>` 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)';
}
53 changes: 53 additions & 0 deletions lib/utils/attachment_guid_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Apple represents an attachment owned by a message as `at_<part>_<message
// guid>`, and OpenBubbles stores the same identity locally as
// `<message guid>_<part>`.
//
// 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)}';
}
Loading