import 'dart:async'; import 'dart:convert'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/html/attachment.dart'; import 'package:bluebubbles/database/html/chat.dart'; import 'package:bluebubbles/database/html/handle.dart'; import 'package:bluebubbles/database/html/objectbox.dart'; import 'package:bluebubbles/database/models.dart' show AttributedBody, MessageSummaryInfo, PayloadData; import 'package:bluebubbles/services/services.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:get/get.dart'; import 'package:metadata_fetch/metadata_fetch.dart'; enum LineType { meToMe, otherToMe, meToOther, otherToOther } class Message { int? id; int? originalROWID; String? guid; int? handleId; int? otherHandle; String? text; String? subject; String? country; DateTime? dateCreated; bool? isFromMe; // Data detector results bool? hasDdResults; DateTime? datePlayed; int? itemType; String? groupTitle; int? groupActionType; String? balloonBundleId; String? associatedMessageGuid; int? associatedMessagePart; String? associatedMessageType; String? expressiveSendStyleId; Handle? handle; bool hasAttachments; bool hasReactions; DateTime? dateDeleted; Map? metadata; String? threadOriginatorGuid; String? threadOriginatorPart; List attachments = []; List associatedMessages = []; bool? bigEmoji; List attributedBody; List messageSummaryInfo; PayloadData? payloadData; bool hasApplePayloadData; bool wasDeliveredQuietly; bool didNotifyRecipient; bool isBookmarked; final RxInt _error = RxInt(0); int get error => _error.value; set error(int i) => _error.value = i; final Rxn _dateRead = Rxn(); DateTime? get dateRead => _dateRead.value; set dateRead(DateTime? d) => _dateRead.value = d; final Rxn _dateDelivered = Rxn(); DateTime? get dateDelivered => _dateDelivered.value; set dateDelivered(DateTime? d) => _dateDelivered.value = d; final Rxn _dateEdited = Rxn(); DateTime? get dateEdited => _dateEdited.value; set dateEdited(DateTime? d) => _dateEdited.value = d; final chat = ToOne(); final dbAttachments = []; Message({ this.id, this.originalROWID, this.guid, this.handleId, this.otherHandle, this.text, this.subject, this.country, int? error, this.dateCreated, DateTime? dateRead, DateTime? dateDelivered, this.isFromMe = true, this.hasDdResults = false, this.datePlayed, this.itemType = 0, this.groupTitle, this.groupActionType = 0, this.balloonBundleId, this.associatedMessageGuid, this.associatedMessagePart, this.associatedMessageType, this.expressiveSendStyleId, this.handle, this.hasAttachments = false, this.hasReactions = false, this.attachments = const [], this.associatedMessages = const [], this.dateDeleted, this.metadata, this.threadOriginatorGuid, this.threadOriginatorPart, this.attributedBody = const [], this.messageSummaryInfo = const [], this.payloadData, this.hasApplePayloadData = false, DateTime? dateEdited, this.wasDeliveredQuietly = false, this.didNotifyRecipient = false, this.isBookmarked = false, }) { if (error != null) _error.value = error; if (dateRead != null) _dateRead.value = dateRead; if (dateDelivered != null) _dateDelivered.value = dateDelivered; if (dateEdited != null) _dateEdited.value = dateEdited; if (attachments.isEmpty) attachments = []; if (associatedMessages.isEmpty) associatedMessages = []; if (attributedBody.isEmpty) attributedBody = []; if (messageSummaryInfo.isEmpty) messageSummaryInfo = []; } factory Message.fromMap(Map json) { final attachments = (json['attachments'] as List? ?? []).map((a) => Attachment.fromMap(a)).toList(); List attributedBody = []; if (json["attributedBody"] != null) { if (json['attributedBody'] is Map) { json['attributedBody'] = [json['attributedBody']]; } try { attributedBody = (json['attributedBody'] as List).map((a) => AttributedBody.fromMap(a)).toList(); } catch (e, stack) { Logger.error('Failed to parse attributed body!', error: e, trace: stack); } } Map metadata = {}; if (!isNullOrEmpty(json["metadata"])) { if (json["metadata"] is String) { try { metadata = jsonDecode(json["metadata"]); } catch (_) {} } else { metadata = json["metadata"]; } } List msi = []; try { msi = (json['messageSummaryInfo'] as List? ?? []).map((e) => MessageSummaryInfo.fromJson(e)).toList(); } catch (e, stack) { Logger.error('Failed to parse summary info!', error: e, trace: stack); } PayloadData? payloadData; try { payloadData = json['payloadData'] == null ? null : PayloadData.fromJson(json['payloadData']); } catch (e, stack) { Logger.error('Failed to parse payload data!', error: e, trace: stack); } return Message( id: json["ROWID"] ?? json['id'], originalROWID: json["originalROWID"], guid: json["guid"], handleId: json["handleId"] ?? 0, otherHandle: json["otherHandle"], text: sanitizeString(json["text"] ?? attributedBody.firstOrNull?.string), subject: json["subject"], country: json["country"], error: json["_error"] ?? 0, dateCreated: parseDate(json["dateCreated"]), dateRead: parseDate(json["dateRead"]), dateDelivered: parseDate(json["dateDelivered"]), isFromMe: json['isFromMe'] == true, hasDdResults: json['hasDdResults'] == true, datePlayed: parseDate(json["datePlayed"]), itemType: json["itemType"], groupTitle: json["groupTitle"], groupActionType: json["groupActionType"] ?? 0, balloonBundleId: json["balloonBundleId"], associatedMessageGuid: json["associatedMessageGuid"]?.toString().replaceAll("bp:", "").split("/").last, associatedMessagePart: json["associatedMessagePart"] ?? int.tryParse(json["associatedMessageGuid"].toString().replaceAll("p:", "").split("/").first), associatedMessageType: json["associatedMessageType"], expressiveSendStyleId: json["expressiveSendStyleId"], handle: json['handle'] != null ? Handle.fromMap(json['handle']) : null, hasAttachments: attachments.isNotEmpty || json['hasAttachments'] == true, attachments: (json['attachments'] as List? ?? []).map((a) => Attachment.fromMap(a)).toList(), hasReactions: json['hasReactions'] == true, dateDeleted: parseDate(json["dateDeleted"]), metadata: metadata is String ? null : metadata, threadOriginatorGuid: json['threadOriginatorGuid'], threadOriginatorPart: json['threadOriginatorPart'], attributedBody: attributedBody, messageSummaryInfo: msi, payloadData: payloadData, hasApplePayloadData: json['hasApplePayloadData'] == true || payloadData != null, dateEdited: parseDate(json["dateEdited"]), wasDeliveredQuietly: json['wasDeliveredQuietly'] ?? false, didNotifyRecipient: json['didNotifyRecipient'] ?? false, isBookmarked: json['isBookmarked'] ?? false, ); } Message save({Chat? chat, bool updateIsBookmarked = false}) { // Save the participant & set the handle ID to the new participant if (handle == null && handleId != null) { handle = Handle.findOne(originalROWID: handleId); } // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type WebListeners.notifyMessage(this, chat: chat); return this; } static Future> bulkSaveNewMessages(Chat chat, List messages) async { for (Message m in messages) { // Save the participant & set the handle ID to the new participant if (m.handle == null && m.handleId != null) { m.handle = Handle.findOne(originalROWID: m.handleId); } // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type WebListeners.notifyMessage(m, chat: chat); } return []; } static List bulkSave(List messages) { for (Message m in messages) { // Save the participant & set the handle ID to the new participant if (m.handle == null && m.handleId != null) { m.handle = Handle.findOne(originalROWID: m.handleId); } // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type WebListeners.notifyMessage(m); } return []; } static Future replaceMessage(String? oldGuid, Message newMessage, {bool awaitNewMessageEvent = true, Chat? chat}) async { if (newMessage.handle == null && newMessage.handleId != null) { newMessage.handle = Handle.findOne(originalROWID: newMessage.handleId); } // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type WebListeners.notifyMessage(newMessage, tempGuid: oldGuid, chat: chat); return newMessage; } Message updateMetadata(Metadata? metadata) { return this; } Message setPlayedDate({ DateTime? timestamp }) { datePlayed = timestamp ?? DateTime.now().toUtc(); return this; } List? fetchAttachments({ChatLifecycleManager? currentChat}) { return attachments; } Chat? getChat() { return null; } Message fetchAssociatedMessages({MessagesService? service, bool shouldRefresh = false}) { associatedMessages = (service?.struct.reactions.where((element) => element.associatedMessageGuid == guid).toList() ?? []).cast(); if (threadOriginatorGuid != null) { final existing = service?.struct.getMessage(threadOriginatorGuid!); final threadOriginator = existing; // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type threadOriginator?.handle ??= Handle.findOne(originalROWID: threadOriginator.handleId); // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type if (threadOriginator != null) associatedMessages.add(threadOriginator); if (existing == null && threadOriginator != null) service?.struct.addThreadOriginator(threadOriginator); } associatedMessages.sort((a, b) => a.originalROWID!.compareTo(b.originalROWID!)); return this; } Handle? getHandle() { // ignore: argument_type_not_assignable, return_of_invalid_type, invalid_assignment, for_in_of_invalid_element_type return chats.webCachedHandles.firstWhereOrNull((element) => element.originalROWID == handleId); } static Message? findOne({String? guid, String? associatedMessageGuid}) { return null; } static List find() { return []; } static void delete(String guid) { return; } static void softDelete(String guid) { return; } String get fullText => sanitizeString([subject, text].where((e) => !isNullOrEmpty(e)).join("\n")); // first condition is for macOS < 11 and second condition is for macOS >= 11 bool get isLegacyUrlPreview => (balloonBundleId == "com.apple.messages.URLBalloonProvider" && hasDdResults!) || (hasDdResults! && (text ?? "").trim().isURL); String? get url => text?.replaceAll("\n", " ").split(" ").firstWhereOrNull((String e) => e.hasUrl); bool get isInteractive => balloonBundleId != null && !isLegacyUrlPreview; String get interactiveText { String text = ""; final temp = balloonBundleIdMap[balloonBundleId?.split(":").first] ?? (balloonBundleId?.split(":").first ?? "Unknown"); if (temp is Map) { text = temp[balloonBundleId?.split(":").last] ?? ((balloonBundleId?.split(":").last ?? "Unknown")); } else { text = temp.toString(); } return text; } String? get interactiveMediaPath { return null; } bool get isGroupEvent => groupTitle != null || (itemType ?? 0) > 0 || (groupActionType ?? 0) > 0; String get groupEventText { String text = "Unknown group event"; handle ??= getHandle(); String name = handle?.displayName ?? 'You'; String? other = "someone"; if (otherHandle != null && isParticipantEvent) { other = Handle.findOne(originalROWID: otherHandle)?.displayName ?? other; } if (itemType == 1 && groupActionType == 1) { text = "$name removed $other from the conversation"; } else if (itemType == 1 && groupActionType == 0) { text = "$name added $other to the conversation"; } else if (itemType == 3 && (groupActionType ?? 0) > 0) { text = "$name changed the group photo"; } else if (itemType == 3) { text = "$name left the conversation"; } else if (itemType == 2 && groupTitle != null) { text = "$name named the conversation \"$groupTitle\""; } else if (itemType == 6) { text = "$name started a FaceTime call"; } else if (itemType == 4 && groupActionType == 0) { text = "$name shared ${name == "You" ? "your" : "their"} location"; } return text; } bool get isParticipantEvent => isGroupEvent && ((itemType == 1 && [0, 1].contains(groupActionType)) || [2, 3].contains(itemType)); bool get isBigEmoji => bigEmoji ?? MessageHelper.shouldShowBigEmoji(fullText); List get realAttachments => attachments.where((e) => e != null && e.mimeType != null).cast().toList(); List get previewAttachments => attachments.where((e) => e != null && e.mimeType == null).cast().toList(); List get reactions => associatedMessages.where((item) => ReactionTypes.toList().contains(item.associatedMessageType?.replaceAll("-", ""))).toList(); Indicator get indicatorToShow { if (!isFromMe!) return Indicator.NONE; if (dateRead != null) return Indicator.READ; if (dateDelivered != null) return Indicator.DELIVERED; if (dateCreated != null) return Indicator.SENT; return Indicator.NONE; } bool showTail(Message? newer) { // if there is no newer, or if the newer is a different sender if (newer == null || !sameSender(newer) || newer.isGroupEvent) return true; // if newer is over a minute newer return newer.dateCreated!.difference(dateCreated!).inMinutes.abs() > 1; } bool sameSender(Message? other) { return (isFromMe! && isFromMe == other?.isFromMe) || (!isFromMe! && !(other?.isFromMe ?? true) && handleId == other?.handleId); } void generateTempGuid() { guid = "temp-${randomString(8)}"; } static int? countForChat(Chat? chat) { return 0; } Message mergeWith(Message otherMessage) { return Message.merge(this, otherMessage); } /// Get what shape the reply line should be LineType getLineType(Message? olderMessage, Message threadOriginator) { if (olderMessage?.threadOriginatorGuid != threadOriginatorGuid) olderMessage = threadOriginator; if (isFromMe! && (olderMessage?.isFromMe ?? false)) { return LineType.meToMe; } else if (!isFromMe! && (olderMessage?.isFromMe ?? false)) { return LineType.meToOther; } else if (isFromMe! && !(olderMessage?.isFromMe ?? false)) { return LineType.otherToMe; } else { return LineType.otherToOther; } } /// Get whether the reply line from the message should connect to the message below bool shouldConnectLower(Message? olderMessage, Message? newerMessage, Message threadOriginator) { // if theres no newer message or it isn't part of the thread, don't connect if (newerMessage == null || newerMessage.threadOriginatorGuid != threadOriginatorGuid) return false; // if the line is from me to other or from other to other, don't connect lower. // we only want lines ending at messages to me to connect downwards (this // helps simplify some things and prevent rendering mistakes) if (getLineType(olderMessage, threadOriginator) == LineType.meToOther || getLineType(olderMessage, threadOriginator) == LineType.otherToOther) return false; // if the lower message isn't from me, then draw the connecting line // (if the message is from me, that message will draw a connecting line up // rather than this message drawing one downwards). return isFromMe != newerMessage.isFromMe; } int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); bool connectToUpper() => threadOriginatorGuid != null; bool showUpperMessage(Message olderMessage) { // find the part count of the older message final olderPartCount = getActiveMwc(olderMessage.guid!)?.parts.length ?? 1; // make sure the older message is none of the following: // 1) thread originator // 2) part of the thread // OR // 1) It is the thread originator but the part is not the last part of the older message // 2) It is part of the thread but has multiple parts return (olderMessage.guid != threadOriginatorGuid && olderMessage.threadOriginatorGuid != threadOriginatorGuid) || (olderMessage.guid == threadOriginatorGuid && normalizedThreadPart != olderPartCount - 1) || (olderMessage.threadOriginatorGuid == threadOriginatorGuid && olderPartCount > 1); } bool connectToLower(Message newerMessage) { final thisPartCount = getActiveMwc(guid!)?.parts.length ?? 1; if (newerMessage.isFromMe != isFromMe) return false; if (newerMessage.normalizedThreadPart != thisPartCount - 1) return false; if (threadOriginatorGuid != null) { return newerMessage.threadOriginatorGuid == threadOriginatorGuid; } else { return newerMessage.threadOriginatorGuid == guid; } } /// Get whether the reply line from the message should connect to the message above bool shouldConnectUpper(Message? olderMessage, Message threadOriginator) { // if theres no older message, or it isn't a part of the thread (make sure // to check that it isn't actually an outlined bubble representing the // thread originator), don't connect if (olderMessage == null || (olderMessage.threadOriginatorGuid != threadOriginatorGuid && !upperIsThreadOriginatorBubble(olderMessage))) { return false; } // if the older message is the outlined bubble, or the originator is from // someone else and the message is from me, then draw the connecting line // (the second condition might be redundant / unnecessary but I left it in // just in case) if (upperIsThreadOriginatorBubble(olderMessage) || (!threadOriginator.isFromMe! && isFromMe!) || getLineType(olderMessage, threadOriginator) == LineType.meToMe || getLineType(olderMessage, threadOriginator) == LineType.otherToMe) return true; // if the upper message is from me, then draw the connecting line // (if the message is not from me, that message will draw a connecting line // down rather than this message drawing one upwards). return isFromMe == olderMessage.isFromMe; } /// Get whether the upper bubble is actually the thread originator as the /// outlined bubble bool upperIsThreadOriginatorBubble(Message? olderMessage) { return olderMessage?.threadOriginatorGuid != threadOriginatorGuid; } static Message merge(Message existing, Message newMessage) { existing.id ??= newMessage.id; existing.guid ??= newMessage.guid; // Update date created if ((existing.dateCreated == null && newMessage.dateCreated != null) || (existing.dateCreated != null && newMessage.dateCreated != null && existing.dateCreated!.millisecondsSinceEpoch < newMessage.dateCreated!.millisecondsSinceEpoch)) { existing.dateCreated = newMessage.dateCreated; } // Update date delivered if ((existing._dateDelivered.value == null && newMessage._dateDelivered.value != null) || (existing._dateDelivered.value != null && newMessage.dateDelivered != null && existing._dateDelivered.value!.millisecondsSinceEpoch < newMessage._dateDelivered.value!.millisecondsSinceEpoch)) { existing._dateDelivered.value = newMessage.dateDelivered; } // Update date delivered if ((existing._dateRead.value == null && newMessage._dateRead.value != null) || (existing._dateRead.value != null && newMessage._dateRead.value != null && existing._dateRead.value!.millisecondsSinceEpoch < newMessage._dateRead.value!.millisecondsSinceEpoch)) { existing._dateRead.value = newMessage.dateRead; } // Update date played if ((existing.datePlayed == null && newMessage.datePlayed != null) || (existing.datePlayed != null && newMessage.datePlayed != null && existing.datePlayed!.millisecondsSinceEpoch < newMessage.datePlayed!.millisecondsSinceEpoch)) { existing.datePlayed = newMessage.datePlayed; } // Update date deleted if ((existing.dateDeleted == null && newMessage.dateDeleted != null) || (existing.dateDeleted != null && newMessage.dateDeleted != null && existing.dateDeleted!.millisecondsSinceEpoch < newMessage.dateDeleted!.millisecondsSinceEpoch)) { existing.dateDeleted = newMessage.dateDeleted; } // Update date edited (and attr body & message summary info) if ((existing.dateEdited == null && newMessage.dateEdited != null) || (existing.dateEdited != null && newMessage.dateEdited != null && existing.dateEdited!.millisecondsSinceEpoch < newMessage.dateEdited!.millisecondsSinceEpoch)) { existing.dateEdited = newMessage.dateEdited; if (!isNullOrEmpty(newMessage.attributedBody)) { existing.attributedBody = newMessage.attributedBody; } if (!isNullOrEmpty(newMessage.messageSummaryInfo)) { existing.messageSummaryInfo = newMessage.messageSummaryInfo; } } // Update error if (existing._error.value != newMessage._error.value) { existing._error.value = newMessage._error.value; } // Update has Dd results if ((existing.hasDdResults == null && newMessage.hasDdResults != null) || (!existing.hasDdResults! && newMessage.hasDdResults!)) { existing.hasDdResults = newMessage.hasDdResults; } // Update metadata existing.metadata = mergeTopLevelDicts(existing.metadata, newMessage.metadata); // Update original ROWID if (existing.originalROWID == null && newMessage.originalROWID != null) { existing.originalROWID = newMessage.originalROWID; } // Update attachments flag if (!existing.hasAttachments && newMessage.hasAttachments) { existing.hasAttachments = newMessage.hasAttachments; } // Update has reactions flag if (!existing.hasReactions && newMessage.hasReactions) { existing.hasReactions = newMessage.hasReactions; } // Update handle if (existing.handle?.id == null && newMessage.handle?.id != null) { existing.handle = newMessage.handle; } // Update attachments if (existing.dbAttachments.isEmpty && newMessage.dbAttachments.isNotEmpty) { existing.dbAttachments.addAll(newMessage.dbAttachments); } if (existing.payloadData == null && newMessage.payloadData != null) { existing.payloadData = newMessage.payloadData; } if (!existing.wasDeliveredQuietly && newMessage.wasDeliveredQuietly) { existing.wasDeliveredQuietly = newMessage.wasDeliveredQuietly; } if (!existing.didNotifyRecipient && newMessage.didNotifyRecipient) { existing.didNotifyRecipient = newMessage.didNotifyRecipient; } existing.isBookmarked = newMessage.isBookmarked; return existing; } Map toMap({bool includeObjects = false}) { final map = { "ROWID": id, "originalROWID": originalROWID, "guid": guid, "handleId": handleId, "otherHandle": otherHandle, "text": sanitizeString(text), "subject": subject, "country": country, "_error": _error.value, "dateCreated": dateCreated?.millisecondsSinceEpoch, "dateRead": _dateRead.value?.millisecondsSinceEpoch, "dateDelivered": _dateDelivered.value?.millisecondsSinceEpoch, "isFromMe": isFromMe!, "hasDdResults": hasDdResults!, "datePlayed": datePlayed?.millisecondsSinceEpoch, "itemType": itemType, "groupTitle": groupTitle, "groupActionType": groupActionType, "balloonBundleId": balloonBundleId, "associatedMessageGuid": associatedMessageGuid, "associatedMessagePart": associatedMessagePart, "associatedMessageType": associatedMessageType, "expressiveSendStyleId": expressiveSendStyleId, "handle": handle?.toMap(), "hasAttachments": hasAttachments, "hasReactions": hasReactions, "dateDeleted": dateDeleted?.millisecondsSinceEpoch, "metadata": jsonEncode(metadata), "threadOriginatorGuid": threadOriginatorGuid, "threadOriginatorPart": threadOriginatorPart, "hasApplePayloadData": hasApplePayloadData, "dateEdited": dateEdited, "wasDeliveredQuietly": wasDeliveredQuietly, "didNotifyRecipient": didNotifyRecipient, "isBookmarked": isBookmarked, }; if (includeObjects) { map['attachments'] = (attachments).map((e) => e!.toMap()).toList(); map['handle'] = handle?.toMap(); map['attributedBody'] = attributedBody.map((e) => e.toMap()).toList(); map['messageSummaryInfo'] = messageSummaryInfo.map((e) => e.toJson()).toList(); map['payloadData'] = payloadData?.toJson(); } return map; } }