Files
2024-10-29 08:20:56 -04:00

1205 lines
48 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:async_task/async_task.dart';
import 'package:bluebubbles/utils/logger/logger.dart';
import 'package:bluebubbles/helpers/helpers.dart';
import 'package:bluebubbles/database/database.dart';
import 'package:bluebubbles/database/models.dart';
import 'package:bluebubbles/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart' hide Condition;
import 'package:metadata_fetch/metadata_fetch.dart';
// (needed when generating objectbox model code)
// ignore: unnecessary_import
import 'package:objectbox/objectbox.dart';
/// Async method to fetch attachments
class GetMessageAttachments extends AsyncTask<List<dynamic>, Map<String, List<Attachment?>>> {
final List<dynamic> stuff;
GetMessageAttachments(this.stuff);
@override
AsyncTask<List<dynamic>, Map<String, List<Attachment?>>> instantiate(List<dynamic> parameters,
[Map<String, SharedData>? sharedData]) {
return GetMessageAttachments(parameters);
}
@override
List<dynamic> parameters() {
return stuff;
}
@override
FutureOr<Map<String, List<Attachment?>>> run() {
/// Pull args from input and create new instances of store and boxes
List<int> messageIds = stuff[0];
final Map<String, List<Attachment?>> map = {};
return Database.runInTransaction(TxMode.read, () {
/// Query the [amJoinBox] for relevant attachment IDs
final messages = Database.messages.getMany(messageIds);
/// Add the attachments to the map with some clever list operations
map.addEntries(messages.mapIndexed((index, e) => MapEntry(e!.guid!, e.dbAttachments)));
return map;
});
}
}
/// Async method to get chats from objectbox
class BulkSaveNewMessages extends AsyncTask<List<dynamic>, List<Message>> {
final List<dynamic> params;
BulkSaveNewMessages(this.params);
@override
AsyncTask<List<dynamic>, List<Message>> instantiate(List<dynamic> parameters, [Map<String, SharedData>? sharedData]) {
return BulkSaveNewMessages(parameters);
}
@override
List<dynamic> parameters() {
return params;
}
@override
FutureOr<List<Message>> run() {
return Database.runInTransaction(TxMode.write, () {
// NOTE: This assumes that handles and chats will already be created and in the database
// 0. Create map for the messages and attachments to save
// 1. Check for existing attachments and save new ones
// 2. Fetch all inserted/existing attachments based on input
// 3. Create map of inserted/existing attachments
// 4. Check for existing messages & create list of new messages to save
// 5. Fetch all handles and map the old handle ROWIDs from each message to the new ones based on the original ROWID
// 6. Relate the attachments to the messages
// 7. Save all messages (and handle/attachment relationships)
// 8. Get the inserted messages
// 9. Check inserted messages for associated message GUIDs & update hasReactions flag
// 10. Save the updated associated messages
// 11. Update the associated chat's last message
/// Takes the list of messages from [params] and saves it
/// to the objectbox Database.
Chat inputChat = params[0];
List<Message> inputMessages = params[1];
List<String> inputMessageGuids = inputMessages.map((element) => element.guid!).toList();
// 0. Create map for the messages and attachments to save
Map<String, Attachment> attachmentsToSave = {};
Map<String, List<String>> messageAttachments = {};
for (final msg in inputMessages) {
for (final a in msg.attachments) {
if (!attachmentsToSave.containsKey(a!.guid)) {
attachmentsToSave[a.guid!] = a;
}
if (!messageAttachments.containsKey(a.guid)) {
messageAttachments[msg.guid!] = [];
}
if (!messageAttachments[msg.guid]!.contains(a.guid)) {
messageAttachments[msg.guid]?.add(a.guid!);
}
}
}
// 1. Check for existing attachments and save new ones
Map<String, Attachment> attachmentMap = {};
if (attachmentsToSave.isNotEmpty) {
List<String> inputAttachmentGuids = attachmentsToSave.values.map((e) => e.guid).whereNotNull().toList();
QueryBuilder<Attachment> attachmentQuery = Database.attachments.query(Attachment_.guid.oneOf(inputAttachmentGuids));
List<String> existingAttachmentGuids =
attachmentQuery.build().find().map((e) => e.guid).whereNotNull().toList();
// Insert the attachments that don't yet exist
List<Attachment> attachmentsToInsert = attachmentsToSave.values
.where((element) => !existingAttachmentGuids.contains(element.guid))
.whereNotNull()
.toList();
Database.attachments.putMany(attachmentsToInsert);
// 2. Fetch all inserted/existing attachments based on input
QueryBuilder<Attachment> attachmentQuery2 = Database.attachments.query(Attachment_.guid.oneOf(inputAttachmentGuids));
List<Attachment> attachments = attachmentQuery2.build().find().whereNotNull().toList();
// 3. Create map of inserted/existing attachments
for (final a in attachments) {
attachmentMap[a.guid!] = a;
}
}
// 4. Check for existing messages & create list of new messages to save
QueryBuilder<Message> query = Database.messages.query(Message_.guid.oneOf(inputMessageGuids));
List<String> existingMessageGuids = query.build().find().map((e) => e.guid!).toList();
inputMessages = inputMessages.where((element) => !existingMessageGuids.contains(element.guid)).toList();
// 5. Fetch all handles and map the old handle ROWIDs from each message to the new ones based on the original ROWID
List<Handle> handles = Database.handles.getAll();
for (final msg in inputMessages) {
msg.chat.target = inputChat;
msg.handle = handles.firstWhereOrNull((e) => e.originalROWID == msg.handleId);
}
// 6. Relate the attachments to the messages
for (final msg in inputMessages) {
final relatedAttachments =
messageAttachments[msg.guid]?.map((e) => attachmentMap[e]).whereNotNull().toList() ?? [];
msg.attachments = relatedAttachments;
msg.dbAttachments.addAll(relatedAttachments);
}
// 7. Save all messages (and handle/attachment relationships)
Database.messages.putMany(inputMessages);
// 8. Get the inserted messages
QueryBuilder<Message> messageQuery = Database.messages.query(Message_.guid.oneOf(inputMessageGuids));
List<Message> messages = messageQuery.build().find().toList();
// 9. Check inserted messages for associated message GUIDs & update hasReactions flag
Map<String, Message> messagesToUpdate = {};
for (final message in messages) {
// Update the handles from our cache
message.handle = handles.firstWhereOrNull((element) => element.originalROWID == message.handleId);
// Continue if there isn't an associated message GUID to process
if ((message.associatedMessageGuid ?? '').isEmpty) continue;
// Find the associated message in the DB and update the hasReactions flag
List<Message> associatedMessages =
Message.find(cond: Message_.guid.equals(message.associatedMessageGuid!)).toList();
if (associatedMessages.isNotEmpty) {
// Toggle the hasReactions flag
Message messageWithReaction = messagesToUpdate[associatedMessages[0].guid] ?? associatedMessages[0];
messageWithReaction.hasReactions = true;
// Make sure the current message has the associated message in it's list, and the hasReactions
// flag is set as well
Message reactionMessage = messagesToUpdate[message.guid!] ?? message;
for (var e in messageWithReaction.associatedMessages) {
if (e.guid == messageWithReaction.guid) {
e.hasReactions = true;
break;
}
}
// Update the cached values
messagesToUpdate[messageWithReaction.guid!] = messageWithReaction;
messagesToUpdate[reactionMessage.guid!] = reactionMessage;
}
}
// 10. Save the updated associated messages
if (messagesToUpdate.isNotEmpty) {
try {
Database.messages.putMany(messagesToUpdate.values.toList());
} catch (ex) {
print('Failed to put associated messages into DB: ${ex.toString()}');
}
}
// 11. Update the associated chat's last message
messages.sort(Message.sort);
bool isNewer = false;
// If the message was saved correctly, update this chat's latestMessage info,
// but only if the incoming message's date is newer
if (messages.isNotEmpty) {
final first = messages.first;
if (first.id != null || kIsWeb) {
isNewer = first.dateCreated!.isAfter(inputChat.latestMessage.dateCreated!);
if (isNewer) {
inputChat.latestMessage = first;
if (!first.isFromMe! && !cm.isChatActive(inputChat.guid)) {
inputChat.toggleHasUnread(true);
}
}
}
}
return messages;
});
}
}
@Entity()
class Message {
int? id;
int? originalROWID;
@Index(type: IndexType.value)
@Unique()
String? guid;
int? handleId;
int? otherHandle;
String? text;
String? subject;
String? country;
@Index()
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<String, dynamic>? metadata;
String? threadOriginatorGuid;
String? threadOriginatorPart;
List<Attachment?> attachments = [];
List<Message> associatedMessages = [];
bool? bigEmoji;
List<AttributedBody> attributedBody;
List<MessageSummaryInfo> 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<DateTime> _dateRead = Rxn<DateTime>();
DateTime? get dateRead => _dateRead.value;
set dateRead(DateTime? d) => _dateRead.value = d;
final Rxn<DateTime> _dateDelivered = Rxn<DateTime>();
DateTime? get dateDelivered => _dateDelivered.value;
set dateDelivered(DateTime? d) => _dateDelivered.value = d;
final RxBool _isDelivered = RxBool(false);
bool get isDelivered => (dateDelivered != null) ? true : _isDelivered.value;
set isDelivered(bool b) => _isDelivered.value = b;
final Rxn<DateTime> _dateEdited = Rxn<DateTime>();
DateTime? get dateEdited => _dateEdited.value;
set dateEdited(DateTime? d) => _dateEdited.value = d;
@Backlink('message')
final dbAttachments = ToMany<Attachment>();
final chat = ToOne<Chat>();
String? get dbAttributedBody => jsonEncode(attributedBody.map((e) => e.toMap()).toList());
set dbAttributedBody(String? json) => attributedBody = json == null
? <AttributedBody>[] : (jsonDecode(json) as List).map((e) => AttributedBody.fromMap(e)).toList();
String? get dbMessageSummaryInfo => jsonEncode(messageSummaryInfo.map((e) => e.toJson()).toList());
set dbMessageSummaryInfo(String? json) => messageSummaryInfo = json == null
? <MessageSummaryInfo>[] : (jsonDecode(json) as List).map((e) => MessageSummaryInfo.fromJson(e)).toList();
String? get dbPayloadData => payloadData == null
? null : jsonEncode(payloadData!.toJson());
set dbPayloadData(String? json) => payloadData = json == null
? null : PayloadData.fromJson(jsonDecode(json));
String? get dbMetadata => metadata == null
? null : jsonEncode(metadata);
set dbMetadata(String? json) => metadata = json == null
? null : jsonDecode(json) as Map<String, dynamic>;
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,
bool? isDelievered,
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 (isDelievered != null) _isDelivered.value = isDelievered;
if (attachments.isEmpty) attachments = [];
if (associatedMessages.isEmpty) associatedMessages = [];
if (attributedBody.isEmpty) attributedBody = [];
if (messageSummaryInfo.isEmpty) messageSummaryInfo = [];
}
factory Message.fromMap(Map<String, dynamic> json) {
final attachments = (json['attachments'] as List? ?? []).map((a) => Attachment.fromMap(a!.cast<String, Object>())).toList();
List<AttributedBody> attributedBody = [];
if (json["attributedBody"] != null) {
if (json['attributedBody'] is Map) {
json['attributedBody'] = [json['attributedBody']!.cast<String, Object>()];
}
try {
attributedBody = (json['attributedBody'] as List).map((a) => AttributedBody.fromMap(a!.cast<String, Object>())).toList();
} catch (e, stack) {
Logger.error('Failed to parse attributed body!', error: e, trace: stack);
}
}
Map<String, dynamic> metadata = {};
if (!isNullOrEmpty(json["metadata"])) {
if (json["metadata"] is String) {
try {
metadata = jsonDecode(json["metadata"]);
} catch (_) {}
} else {
metadata = json["metadata"]?.cast<String, Object>();
}
}
List<MessageSummaryInfo> msi = [];
try {
msi = (json['messageSummaryInfo'] as List? ?? []).map((e) => MessageSummaryInfo.fromJson(e!.cast<String, Object>())).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, s) {
Logger.error('Failed to parse payload data!', error: e, trace: s);
}
return Message(
id: json["ROWID"] ?? json['id'],
originalROWID: json["originalROWID"],
guid: json["guid"],
handleId: json["handleId"] ?? 0,
otherHandle: json["otherHandle"],
text: sanitizeString(attributedBody.firstOrNull?.string ?? json["text"]),
subject: json["subject"],
country: json["country"],
error: json["error"] ?? json["_error"] ?? 0,
dateCreated: parseDate(json["dateCreated"]),
dateRead: parseDate(json["dateRead"]),
dateDelivered: parseDate(json["dateDelivered"]),
isDelievered: json["isDelivered"] ?? false,
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']!.cast<String, Object>()) : null,
hasAttachments: attachments.isNotEmpty || json['hasAttachments'] == true,
attachments: (json['attachments'] as List? ?? []).map((a) => Attachment.fromMap(a!.cast<String, Object>())).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,
);
}
/// Save a single message - prefer [bulkSave] for multiple messages rather
/// than iterating through them
Message save({Chat? chat, bool updateIsBookmarked = false}) {
if (kIsWeb) return this;
Database.runInTransaction(TxMode.write, () {
Message? existing = Message.findOne(guid: guid);
if (existing != null) {
id = existing.id;
text ??= existing.text;
}
// Save the participant & set the handle ID to the new participant
if (handle == null && handleId != null) {
handle = Handle.findOne(originalROWID: handleId);
}
// Save associated messages or the original message (depending on whether
// this message is a reaction or regular message
if (associatedMessageType != null && associatedMessageGuid != null) {
Message? associatedMessage = Message.findOne(guid: associatedMessageGuid);
if (associatedMessage != null) {
associatedMessage.hasReactions = true;
associatedMessage.save();
}
} else if (!hasReactions) {
Message? reaction = Message.findOne(associatedMessageGuid: guid);
if (reaction != null) {
hasReactions = true;
}
}
if (!updateIsBookmarked) {
isBookmarked = existing?.isBookmarked ?? isBookmarked;
}
try {
if (chat != null) this.chat.target = chat;
id = Database.messages.put(this);
} on UniqueViolationException catch (_) {}
});
return this;
}
static Future<List<Message>> bulkSaveNewMessages(Chat chat, List<Message> messages) async {
if (kIsWeb) throw Exception("Web does not support saving messages!");
final task = BulkSaveNewMessages([chat, messages]);
return (await createAsyncTask<List<Message>>(task)) ?? [];
}
/// Save a list of messages
static List<Message> bulkSave(List<Message> messages) {
Database.runInTransaction(TxMode.write, () {
/// Find existing messages and match them to the messages to save, where
/// possible
List<Message> existingMessages = Message.find(cond: Message_.guid.oneOf(messages.map((e) => e.guid!).toList()));
for (Message m in messages) {
final existingMessage = existingMessages.firstWhereOrNull((e) => e.guid == m.guid);
if (existingMessage != null) {
m.id = existingMessage.id;
m.text ??= existingMessage.text;
}
}
/// Save the messages and update their IDs
/// We do this first because we might want these same messages to show up
/// in the next queries
final ids = Database.messages.putMany(messages);
for (int i = 0; i < messages.length; i++) {
messages[i].id = ids[i];
}
/// Find associated messages or original messages
List<Message> associatedMessages =
Message.find(cond: Message_.guid.oneOf(messages.map((e) => e.associatedMessageGuid ?? "").toList()));
List<Message> originalMessages =
Message.find(cond: Message_.associatedMessageGuid.oneOf(messages.map((e) => e.guid!).toList()));
/// Iterate thru messages and update the associated message or the original
/// message, and update original message handle data
for (Message m in messages) {
if (m.associatedMessageType != null && m.associatedMessageGuid != null) {
final associatedMessageList = associatedMessages.where((e) => e.guid == m.associatedMessageGuid);
for (Message am in associatedMessageList) {
am.hasReactions = true;
}
} else if (!m.hasReactions) {
final originalMessage = originalMessages.firstWhereOrNull((e) => e.associatedMessageGuid == m.guid);
if (originalMessage != null) {
m.hasReactions = true;
}
}
}
associatedMessages.removeWhere((message) {
Message? _message = messages.firstWhereOrNull((e) => e.guid == message.guid);
_message?.hasReactions = message.hasReactions;
return _message != null;
});
try {
/// Update the original messages and associated messages
final ids = Database.messages.putMany(messages..addAll(associatedMessages));
for (int i = 0; i < messages.length; i++) {
messages[i].id = ids[i];
}
} on UniqueViolationException catch (_) {}
});
return messages;
}
/// Replace a temp message with the message from the server
static Future<Message> replaceMessage(String? oldGuid, Message newMessage) async {
Message? existing = Message.findOne(guid: oldGuid);
if (existing == null) {
throw Exception("Cannot replace on a null existing message!!");
}
// We just need to update the timestamps & error
if (existing.guid != newMessage.guid) {
existing.guid = newMessage.guid;
}
if (newMessage.text != null) {
existing.text = newMessage.text;
}
existing._dateDelivered.value = newMessage._dateDelivered.value ?? existing._dateDelivered.value;
existing._isDelivered.value = newMessage._isDelivered.value;
existing._dateRead.value = newMessage._dateRead.value ?? existing._dateRead.value;
existing._dateEdited.value = newMessage._dateEdited.value ?? existing._dateEdited.value;
existing.attributedBody = newMessage.attributedBody.isNotEmpty ? newMessage.attributedBody : existing.attributedBody;
existing.messageSummaryInfo = newMessage.messageSummaryInfo.isNotEmpty ? newMessage.messageSummaryInfo : existing.messageSummaryInfo;
existing.payloadData = newMessage.payloadData ?? existing.payloadData;
existing.wasDeliveredQuietly = newMessage.wasDeliveredQuietly ? newMessage.wasDeliveredQuietly : existing.wasDeliveredQuietly;
existing.didNotifyRecipient = newMessage.didNotifyRecipient ? newMessage.didNotifyRecipient : existing.didNotifyRecipient;
existing._error.value = newMessage._error.value;
try {
Database.messages.put(existing, mode: PutMode.update);
} catch (ex, stack) {
Logger.error('Failed to replace message! This is likely due to a unique constraint being violated.', error: ex, trace: stack);
}
return existing;
}
Message updateMetadata(Metadata? metadata) {
if (kIsWeb || id == null) return this;
this.metadata = metadata!.toJson();
save();
return this;
}
Message setPlayedDate({DateTime? timestamp}) {
datePlayed = timestamp ?? DateTime.now().toUtc();
save();
return this;
}
/// Fetch attachments for a single message. Prefer using [fetchAttachmentsByMessages]
/// or [fetchAttachmentsByMessagesAsync] when working with a list of messages.
List<Attachment?>? fetchAttachments({ChatLifecycleManager? currentChat}) {
if (attachments.isNotEmpty) {
return attachments;
}
return Database.runInTransaction(TxMode.read, () {
attachments = dbAttachments;
return attachments;
});
}
/// Get the chat associated with the message
Chat? getChat() {
if (kIsWeb) return null;
return Database.runInTransaction(TxMode.read, () {
return chat.target;
});
}
/// Fetch reactions
Message fetchAssociatedMessages({MessagesService? service, bool shouldRefresh = false}) {
associatedMessages = Message.find(cond: Message_.associatedMessageGuid.equals(guid ?? ""));
associatedMessages = MessageHelper.normalizedAssociatedMessages(associatedMessages);
if (threadOriginatorGuid != null) {
final existing = service?.struct.getMessage(threadOriginatorGuid!);
final threadOriginator = existing ?? Message.findOne(guid: threadOriginatorGuid);
threadOriginator?.handle ??= threadOriginator.getHandle();
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() {
if (kIsWeb || handleId == 0 || handleId == null) return null;
return Handle.findOne(originalROWID: handleId!);
}
static Message? findOne({String? guid, String? associatedMessageGuid}) {
if (kIsWeb) return null;
if (guid != null) {
final query = Database.messages.query(Message_.guid.equals(guid)).build();
query.limit = 1;
final result = query.findFirst();
query.close();
result?.handle = result.getHandle();
return result;
} else if (associatedMessageGuid != null) {
final query = Database.messages.query(Message_.associatedMessageGuid.equals(associatedMessageGuid)).build();
query.limit = 1;
final result = query.findFirst();
query.close();
result?.handle = result.getHandle();
return result;
}
return null;
}
/// Find a list of messages by the specified condition, or return all messages
/// when no condition is specified
static List<Message> find({Condition<Message>? cond}) {
final query = Database.messages.query(cond).build();
return query.find();
}
/// Delete a message and remove all instances of that message in the DB
static void delete(String guid) {
if (kIsWeb) return;
Database.runInTransaction(TxMode.write, () {
final query = Database.messages.query(Message_.guid.equals(guid)).build();
final result = query.findFirst();
query.close();
if (result?.id != null) {
Database.messages.remove(result!.id!);
}
});
}
static void softDelete(String guid) {
if (kIsWeb) return;
Message? toDelete = Message.findOne(guid: guid);
toDelete?.dateDeleted = DateTime.now().toUtc();
toDelete?.save();
}
/// This is purely because some Macs incorrectly report the dateCreated time
static int sort(Message a, Message b, {bool descending = true}) {
late DateTime aDateToUse;
if (a.dateDelivered == null) {
aDateToUse = a.dateCreated!;
} else {
aDateToUse = a.dateCreated!.isBefore(a.dateDelivered!) ? a.dateCreated! : a.dateDelivered!;
}
late DateTime bDateToUse;
if (b.dateDelivered == null) {
bDateToUse = b.dateCreated!;
} else {
bDateToUse = b.dateCreated!.isBefore(b.dateDelivered!) ? b.dateCreated! : b.dateDelivered!;
}
return descending ? bDateToUse.compareTo(aDateToUse) : aDateToUse.compareTo(bDateToUse);
}
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! || isFromMe!) && (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 = "";
if (payloadData?.urlData != null && payloadData!.urlData!.isNotEmpty && payloadData?.urlData?.first.url != null) {
final uri = Uri.parse(payloadData!.urlData!.first.url!);
return "Website: ${payloadData!.urlData!.first.title} (${uri.host.replaceFirst('www.', '')})";
}
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 {
final extension = balloonBundleId!.contains("com.apple.Digital") ? ".mov" : balloonBundleId!.contains("com.apple.Handwriting") ? ".png" : null;
return "${fs.appDocDir.path}/messages/$guid/embedded-media/$balloonBundleId$extension";
}
bool get isGroupEvent => groupTitle != null || (itemType ?? 0) > 0 || (groupActionType ?? 0) > 0;
String get groupEventText {
String text = "Unknown group event";
String name = handle?.displayName ?? 'You';
String? other = "someone";
if (otherHandle != null && isParticipantEvent) {
other = Handle.findOne(originalROWID: otherHandle)?.displayName;
}
if (itemType == 1) {
if (groupActionType == 0) {
text = "$name added $other to the conversation";
} else if (groupActionType == 1) {
text = "$name removed $other from the conversation";
}
} else if (itemType == 2) {
if (groupTitle != null) {
text = "$name named the conversation \"$groupTitle\"";
} else {
text = "$name removed the name from the conversation";
}
} else if (itemType == 3) {
if (groupActionType == null || groupActionType == 0) {
text = "$name left the conversation";
} else if (groupActionType == 1) {
text = "$name changed the group photo";
} else if (groupActionType == 2) {
text = "$name removed the group photo";
}
} else if (itemType == 4 && groupActionType == 0) {
text = "$name shared ${name == "You" ? "your" : "their"} location";
} else if (itemType == 5) {
text = "$name kept an audio message";
} else if (itemType == 6) {
text = "$name started a FaceTime call";
}
return text;
}
bool get isParticipantEvent => isGroupEvent && ((itemType == 1 && [0, 1].contains(groupActionType)) || [2, 3].contains(itemType));
bool get isBigEmoji => bigEmoji ?? MessageHelper.shouldShowBigEmoji(fullText);
List<Attachment> get realAttachments => attachments.where((e) => e != null && e.mimeType != null).cast<Attachment>().toList();
List<Attachment> get previewAttachments => attachments.where((e) => e != null && e.mimeType == null).cast<Attachment>().toList();
List<Message> 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 (isDelivered) return Indicator.DELIVERED;
if (dateDelivered != null) return Indicator.DELIVERED;
if (dateCreated != null) return Indicator.SENT;
return Indicator.NONE;
}
bool get hasAudioTranscript => attributedBody.any((i) => i.runs.any((e) => e.attributes?.audioTranscript != null));
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)}";
}
/// Find how many messages exist in the DB for a chat
static int? countForChat(Chat? chat) {
if (kIsWeb || chat == null || chat.id == null) return 0;
return chat.messages.length;
}
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 with the same thread partIndex
// 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.normalizedThreadPart != normalizedThreadPart))
|| (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;
}
/// Calculate the size of the message bubble by calculating text size or
/// attachment size
Size getBubbleSize(BuildContext context,
{double? maxWidthOverride, double? minHeightOverride, String? textOverride}) {
// cache this value because the calculation can be expensive
if (MessagesService.cachedBubbleSizes[guid!] != null) return MessagesService.cachedBubbleSizes[guid!]!;
// if attachment, then grab width / height
if (fullText.isEmpty && (attachments).isNotEmpty) {
return Size(
attachments
.map((e) => e!.width)
.fold(0, (p, e) => max(p, (e ?? ns.width(context) / 2).toDouble()) + 28),
attachments
.map((e) => e!.height)
.fold(0, (p, e) => max(p, (e ?? ns.width(context) / 2).toDouble())));
}
// initialize constraints for text rendering
final fontSizeFactor = isBigEmoji ? bigEmojiScaleFactor : 1.0;
final constraints = BoxConstraints(
maxWidth: maxWidthOverride ?? ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 30,
minHeight: minHeightOverride ?? Theme.of(context).textTheme.bodySmall!.fontSize! * fontSizeFactor,
);
final renderParagraph = RichText(
text: TextSpan(
text: textOverride ?? fullText,
style: context.theme.textTheme.bodySmall!.apply(color: Colors.white, fontSizeFactor: fontSizeFactor),
),
).createRenderObject(context);
// get the text size
Size size = renderParagraph.getDryLayout(constraints);
// if the text is shorter than the full width, add 28 to account for the
// container margins
if (size.height < context.theme.textTheme.bodySmall!.fontSize! * 2 * fontSizeFactor ||
(subject != null && size.height < context.theme.textTheme.bodySmall!.fontSize! * 3 * fontSizeFactor)) {
size = Size(size.width + 28, size.height);
}
// if we have a URL preview, extend to the full width
if (isLegacyUrlPreview) {
size = Size(ns.width(context) * 2 / 3 - 30, size.height);
}
// if we have reactions, account for the extra height they add
if (hasReactions) {
size = Size(size.width, size.height + 25);
}
// add 16 to the height to account for container margins
size = Size(size.width, size.height + 16);
// cache the value
MessagesService.cachedBubbleSizes[guid!] = size;
return size;
}
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 is delivered
if (existing._isDelivered.value != newMessage._isDelivered.value) {
existing._isDelivered.value = newMessage._isDelivered.value;
}
// Update date read
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 chat
if (!existing.chat.hasValue && newMessage.chat.hasValue) {
existing.chat.target = newMessage.chat.target;
}
// 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;
}
String getLastUpdate() {
if (dateEdited != null) {
return "Edited at $dateEdited";
} else if (datePlayed != null) {
return "Played at $datePlayed";
} else if (dateRead != null) {
return "Read at $dateRead";
} else if (dateDelivered != null) {
return "Delivered at $dateDelivered";
} else if (isDelivered) {
return "Delivered";
} else {
return "Sent at $dateCreated";
}
}
bool isNewerThan(Message other) {
// If the other message has an error, we want to show that.
if (error == 0 && other.error != 0) return false;
// Check null dates in order of what should be filled in first -> last
if (dateCreated == null && other.dateCreated != null) return false;
if (dateCreated != null && other.dateCreated == null) return true;
if (!isDelivered && other.isDelivered) return false;
if (isDelivered && !other.isDelivered) return true;
if (dateDelivered == null && other.dateDelivered != null) return false;
if (dateDelivered != null && other.dateDelivered == null) return true;
if (dateRead == null && other.dateRead != null) return false;
if (dateRead != null && other.dateRead == null) return true;
if (datePlayed == null && other.datePlayed != null) return false;
if (datePlayed != null && other.datePlayed == null) return true;
if (dateEdited == null && other.dateEdited != null) return false;
if (dateEdited != null && other.dateEdited == null) return true;
// Once we verify that all aren't null, we can start comparing dates.
// Compare the dates in the opposite order of what should be filled in last -> first
if (dateEdited != null && other.dateEdited != null) {
return dateEdited!.millisecondsSinceEpoch > other.dateEdited!.millisecondsSinceEpoch;
} else if (datePlayed != null && other.datePlayed != null) {
return datePlayed!.millisecondsSinceEpoch > other.datePlayed!.millisecondsSinceEpoch;
} else if (dateRead != null && other.dateRead != null) {
return dateRead!.millisecondsSinceEpoch > other.dateRead!.millisecondsSinceEpoch;
} else if (dateDelivered != null && other.dateDelivered != null) {
return dateDelivered!.millisecondsSinceEpoch > other.dateDelivered!.millisecondsSinceEpoch;
} else if (dateCreated != null && other.dateCreated != null) {
return dateCreated!.millisecondsSinceEpoch > other.dateCreated!.millisecondsSinceEpoch;
}
return false;
}
Map<String, dynamic> 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,
"isDelivered": _isDelivered.value,
"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(includeObjects: true),
"hasAttachments": hasAttachments,
"hasReactions": hasReactions,
"dateDeleted": dateDeleted?.millisecondsSinceEpoch,
"metadata": jsonEncode(metadata),
"threadOriginatorGuid": threadOriginatorGuid,
"threadOriginatorPart": threadOriginatorPart,
"hasApplePayloadData": hasApplePayloadData,
"dateEdited": dateEdited?.millisecondsSinceEpoch,
"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;
}
}