Files
Morgan Pretty 03df067dc8 Fixed some build errors, resolved some TODOs, fixed a couple of bugs
• Added logic to handle non-originating accounts
• Added explicit SessionPro errors
• Moved profile pro decisions into the SessionProManager to consolidate the logic
• Resolved a bunch of TODOs
• Fixed an issue where a invalid pro key would prevent messages from decoding
2025-12-17 13:10:34 +11:00

771 lines
33 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionNetworkingKit
import SessionUtilitiesKit
extension MessageReceiver {
public typealias InsertedInteractionInfo = (
threadId: String,
threadVariant: SessionThread.Variant,
interactionId: Int64,
interactionVariant: Interaction.Variant?,
wasRead: Bool,
numPreviousInteractionsForMessageRequest: Int
)
internal static func handleVisibleMessage(
_ db: ObservingDatabase,
threadId: String,
threadVariant: SessionThread.Variant,
message: VisibleMessage,
decodedMessage: DecodedMessage,
serverExpirationTimestamp: TimeInterval?,
suppressNotifications: Bool,
currentUserSessionIds: Set<String>,
using dependencies: Dependencies
) throws -> InsertedInteractionInfo {
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
// Update profile if needed
if let profile = message.profile {
try Profile.updateIfNeeded(
db,
publicKey: decodedMessage.sender.hexString,
displayNameUpdate: .contactUpdate(profile.displayName),
displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove),
blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests),
proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)),
profileUpdateTimestamp: profile.updateTimestampSeconds,
currentUserSessionIds: currentUserSessionIds,
using: dependencies
)
}
switch threadVariant {
case .contact: break // Always continue
case .community:
// Only process visible messages for communities if they have an existing thread
guard (try? SessionThread.exists(db, id: threadId)) == true else {
throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist
}
case .legacyGroup, .group:
// Only process visible messages for groups if they have a ClosedGroup record
guard (try? ClosedGroup.exists(db, id: threadId)) == true else {
throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist
}
}
// Store the message variant so we can run variant-specific behaviours
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let thread: SessionThread = try SessionThread.upsert(
db,
id: threadId,
variant: threadVariant,
values: SessionThread.TargetValues(
creationDateTimestamp: .useExistingOrSetTo(
TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000)
),
shouldBeVisible: .useExisting
),
using: dependencies
)
let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = {
guard threadVariant == .community else { return nil }
return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId)
}()
let variant: Interaction.Variant = try {
guard let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else {
return (decodedMessage.sender == userSessionId ?
.standardOutgoing :
.standardIncoming
)
}
// Need to check if the blinded id matches for open groups
switch decodedMessage.sender.prefix {
case .blinded15, .blinded25:
guard
dependencies[singleton: .crypto].verify(
.sessionId(
userSessionId.hexString,
matchesBlindedId: decodedMessage.sender.hexString,
serverPublicKey: openGroupUrlInfo.publicKey
)
)
else { return .standardIncoming }
return .standardOutgoing
case .standard, .unblinded:
return (decodedMessage.sender == userSessionId ?
.standardOutgoing :
.standardIncoming
)
case .group, .versionBlinded07:
Log.info(.messageReceiver, "Ignoring message with invalid sender.")
throw MessageError.invalidSender
}
}()
let generateCurrentUserSessionIds: () -> Set<String> = {
guard threadVariant == .community else { return [userSessionId.hexString] }
let openGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo
.fetchOne(db, id: threadId)
return Set([
userSessionId,
SessionThread.getCurrentUserBlindedSessionId(
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded15,
openGroupCapabilityInfo: openGroupCapabilityInfo,
using: dependencies
),
SessionThread.getCurrentUserBlindedSessionId(
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded25,
openGroupCapabilityInfo: openGroupCapabilityInfo,
using: dependencies
)
].compactMap { $0 }.map { $0.hexString })
}
// Handle emoji reacts first (otherwise it's essentially an invalid message)
if let interactionId: Int64 = try handleEmojiReactIfNeeded(
db,
thread: thread,
message: message,
decodedMessage: decodedMessage,
openGroupUrlInfo: openGroupUrlInfo,
currentUserSessionIds: generateCurrentUserSessionIds(),
suppressNotifications: suppressNotifications,
using: dependencies
) {
return (threadId, threadVariant, interactionId, nil, true, 0)
}
// Try to insert the interaction
//
// Note: There are now a number of unique constraints on the database which
// prevent the ability to insert duplicate interactions at a database level
// so we don't need to check for the existance of a message beforehand anymore
let interaction: Interaction
// Auto-mark sent messages or messages older than the 'lastReadTimestampMs' as read
let wasRead: Bool = (
variant == .standardOutgoing ||
dependencies.mutate(cache: .libSession) { cache in
cache.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: decodedMessage.sentTimestampMs,
openGroupUrlInfo: openGroupUrlInfo
)
}
)
let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo(
threadVariant: thread.variant,
wasRead: wasRead,
serverExpirationTimestamp: serverExpirationTimestamp,
expiresInSeconds: message.expiresInSeconds,
expiresStartedAtMs: message.expiresStartedAtMs,
using: dependencies
)
do {
let processedMessageBody: String? = processMessageBody(
message.text,
decodedMessage: decodedMessage,
threadVariant: thread.variant,
dependencies: dependencies
)
interaction = try Interaction(
serverHash: message.serverHash, // Keep track of server hash
threadId: thread.id,
threadVariant: thread.variant,
authorId: decodedMessage.sender.hexString,
variant: variant,
body: processedMessageBody,
timestampMs: Int64(decodedMessage.sentTimestampMs),
wasRead: wasRead,
hasMention: Interaction.isUserMentioned(
db,
threadId: thread.id,
body: processedMessageBody,
quoteAuthorId: message.quote?.authorId,
using: dependencies
),
expiresInSeconds: messageExpirationInfo.expiresInSeconds,
expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs,
// OpenGroupInvitations are stored as LinkPreview's in the database
linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url),
// Keep track of the open group server message ID ↔ message ID relationship
openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) },
openGroupWhisper: message.openGroupWhisper,
openGroupWhisperMods: message.openGroupWhisperMods,
openGroupWhisperTo: message.openGroupWhisperTo,
// If we received an outgoing message then we can assume the interaction has already
// been sent, otherwise we should just use whatever the default state is
state: (variant == .standardOutgoing ? .sent : nil),
proMessageFeatures: (message.proMessageFeatures ?? .none),
proProfileFeatures: (message.proProfileFeatures ?? .none),
using: dependencies
).inserted(db)
}
catch {
switch error {
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE:
guard
variant == .standardOutgoing,
let existingInteractionId: Int64 = try? Interaction
.select(.id)
.filter(Interaction.Columns.threadId == thread.id)
.filter(Interaction.Columns.timestampMs == decodedMessage.sentTimestampMs)
.filter(Interaction.Columns.variant == variant)
.filter(Interaction.Columns.authorId == decodedMessage.sender.hexString)
.asRequest(of: Int64.self)
.fetchOne(db)
else { break }
// If we receive an outgoing message that already exists in the database
// then we still need to update the recipient and read states for the
// message (even if we don't need to do anything else)
try updateRecipientAndReadStatesForOutgoingInteraction(
db,
thread: thread,
interactionId: existingInteractionId,
messageSentTimestampMs: decodedMessage.sentTimestampMs,
variant: variant,
syncTarget: message.syncTarget,
using: dependencies
)
Message.getExpirationForOutgoingDisappearingMessages(
db,
threadId: threadId,
threadVariant: threadVariant,
variant: variant,
serverHash: message.serverHash,
expireInSeconds: message.expiresInSeconds,
using: dependencies
)
default: break
}
throw error
}
guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave }
// Update and recipient and read states as needed
try updateRecipientAndReadStatesForOutgoingInteraction(
db,
thread: thread,
interactionId: interactionId,
messageSentTimestampMs: decodedMessage.sentTimestampMs,
variant: variant,
syncTarget: message.syncTarget,
using: dependencies
)
if messageExpirationInfo.shouldUpdateExpiry {
Message.updateExpiryForDisappearAfterReadMessages(
db,
threadId: threadId,
threadVariant: threadVariant,
serverHash: message.serverHash,
expiresInSeconds: message.expiresInSeconds,
expiresStartedAtMs: message.expiresStartedAtMs,
using: dependencies
)
}
Message.getExpirationForOutgoingDisappearingMessages(
db,
threadId: threadId,
threadVariant: threadVariant,
variant: variant,
serverHash: message.serverHash,
expireInSeconds: message.expiresInSeconds,
using: dependencies
)
// Parse & persist attachments
let proto: SNProtoContent = try decodedMessage.decodeProtoContent()
var attachments: [Attachment] = []
if
let protoAttachments: [SNProtoAttachmentPointer] = proto.dataMessage?.attachments,
!protoAttachments.isEmpty
{
attachments = try protoAttachments
.compactMap { proto -> Attachment? in
let attachment: Attachment = Attachment(proto: proto)
// Attachments on received messages must have a 'downloadUrl' otherwise
// they are invalid and we can ignore them
return (attachment.downloadUrl != nil ? attachment : nil)
}
.enumerated()
.map { index, attachment in
let savedAttachment: Attachment = try attachment.upserted(db)
// Link the attachment to the interaction and add to the id lookup
try InteractionAttachment(
albumIndex: index,
interactionId: interactionId,
attachmentId: savedAttachment.id
).insert(db)
return savedAttachment
}
message.attachmentIds = attachments.map { $0.id }
}
// Persist quote if needed
if let quote: VisibleMessage.VMQuote = message.quote {
try? Quote(
interactionId: interactionId,
authorId: quote.authorId,
timestampMs: Int64(quote.timestamp)
).insert(db)
}
// Parse link preview if needed
var linkPreviewAttachmentId: String?
if let linkPreview: VisibleMessage.VMLinkPreview = message.linkPreview {
let linkPreview: LinkPreview? = try? LinkPreview(
db,
linkPreview: linkPreview,
sentTimestampMs: decodedMessage.sentTimestampMs
)
_ = try? linkPreview?.upserted(db)
linkPreviewAttachmentId = linkPreview?.attachmentId
}
// Open group invitations are stored as LinkPreview values so create one if needed
if
let openGroupInvitationUrl: String = message.openGroupInvitation?.url,
let openGroupInvitationName: String = message.openGroupInvitation?.name
{
try LinkPreview(
url: openGroupInvitationUrl,
timestamp: LinkPreview.timestampFor(sentTimestampMs: decodedMessage.sentTimestampMs),
variant: .openGroupInvitation,
title: openGroupInvitationName,
using: dependencies
).upsert(db)
}
// Start attachment downloads if needed (ie. trusted contact or group thread)
// FIXME: Replace this to check the `autoDownloadAttachments` flag we are adding to threads
let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isTrusted ?? false)
if isContactTrusted || thread.variant != .contact {
attachments
.map { $0.id }
.appending(linkPreviewAttachmentId)
.forEach { attachmentId in
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .attachmentDownload,
threadId: thread.id,
interactionId: interactionId,
details: AttachmentDownloadJob.Details(
attachmentId: attachmentId
)
),
canStartJob: isMainAppActive
)
}
}
// Cancel any typing indicators if needed
if isMainAppActive {
Task {
await dependencies[singleton: .typingIndicators].didStopTyping(
threadId: thread.id,
direction: .incoming
)
}
}
// Update the contact's approval status of the current user if needed (if we are getting messages from
// them outside of a group then we can assume they have approved the current user)
//
// Note: This is to resolve a rare edge-case where a conversation was started with a user on an old
// version of the app and their message request approval state was set via a migration rather than
// by using the approval process
switch thread.variant {
case .contact:
try MessageReceiver.updateContactApprovalStatusIfNeeded(
db,
senderSessionId: decodedMessage.sender.hexString,
threadId: thread.id,
using: dependencies
)
case .group:
try MessageReceiver.updateMemberApprovalStatusIfNeeded(
db,
senderSessionId: decodedMessage.sender.hexString,
groupSessionIdHexString: thread.id,
profile: nil, // Don't update the profile in this case
using: dependencies
)
default: break
}
// Notify the user if needed
guard
!suppressNotifications &&
variant == .standardIncoming &&
!interaction.wasRead
else { return (threadId, threadVariant, interactionId, variant, interaction.wasRead, 0) }
let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in
cache.isMessageRequest(
threadId: threadId,
threadVariant: threadVariant
)
}
let numPreviousInteractionsForMessageRequest: Int = {
guard isMessageRequest else { return 0 }
switch interaction.serverHash {
case .some(let serverHash):
return (try? Interaction
.filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.serverHash != serverHash)
.fetchCount(db))
.defaulting(to: 0)
case .none:
return (try? Interaction
.filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.timestampMs != interaction.timestampMs)
.fetchCount(db))
.defaulting(to: 0)
}
}()
try? dependencies[singleton: .notificationsManager].notifyUser(
cat: .messageReceiver,
message: message,
threadId: threadId,
threadVariant: threadVariant,
interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"),
interactionVariant: interaction.variant,
attachmentDescriptionInfo: attachments.map { $0.descriptionInfo },
openGroupUrlInfo: openGroupUrlInfo,
applicationState: (isMainAppActive ? .active : .background),
extensionBaseUnreadCount: nil,
currentUserSessionIds: generateCurrentUserSessionIds(),
displayNameRetriever: { sessionId, _ in
Profile.displayNameNoFallback(
db,
id: sessionId,
threadVariant: threadVariant
)
},
groupNameRetriever: { threadId, threadVariant in
switch threadVariant {
case .group:
let groupId: SessionId = SessionId(.group, hex: threadId)
return dependencies.mutate(cache: .libSession) { cache in
cache.groupName(groupSessionId: groupId)
}
case .community:
return try? OpenGroup
.select(.name)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
default: return nil
}
},
shouldShowForMessageRequest: {
// We only want to show a notification for the first interaction in the thread
return (numPreviousInteractionsForMessageRequest == 0)
}
)
return (
threadId,
threadVariant,
interactionId,
variant,
interaction.wasRead,
numPreviousInteractionsForMessageRequest
)
}
private static func handleEmojiReactIfNeeded(
_ db: ObservingDatabase,
thread: SessionThread,
message: VisibleMessage,
decodedMessage: DecodedMessage,
openGroupUrlInfo: LibSession.OpenGroupUrlInfo?,
currentUserSessionIds: Set<String>,
suppressNotifications: Bool,
using dependencies: Dependencies
) throws -> Int64? {
guard let vmReaction: VisibleMessage.VMReaction = message.reaction else { return nil }
// Since we have database access here make sure the original message for this reaction exists
// before handling it or showing a notification
let maybeInteractionId: Int64? = try? Interaction
.select(.id)
.filter(Interaction.Columns.threadId == thread.id)
.filter(Interaction.Columns.timestampMs == vmReaction.timestamp)
.filter(Interaction.Columns.authorId == vmReaction.publicKey)
.filter(Interaction.Columns.variant != Interaction.Variant.standardIncomingDeleted)
.filter(Interaction.Columns.state != Interaction.State.deleted)
.asRequest(of: Int64.self)
.fetchOne(db)
guard let interactionId: Int64 = maybeInteractionId else {
throw StorageError.objectNotFound
}
let sortId = Reaction.getSortId(
db,
interactionId: interactionId,
emoji: vmReaction.emoji
)
switch vmReaction.kind {
case .react:
// Determine whether the app is active based on the prefs rather than the UIApplication state to avoid
// requiring main-thread execution
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
let userSessionId: SessionId = dependencies[cache: .general].sessionId
try Reaction(
interactionId: interactionId,
serverHash: message.serverHash,
timestampMs: Int64(decodedMessage.sentTimestampMs),
authorId: decodedMessage.sender.hexString,
emoji: vmReaction.emoji,
count: 1,
sortId: sortId
).insert(db)
// Notify of reaction event
db.addReactionEvent(
id: db.lastInsertedRowID,
messageId: interactionId,
change: .added(vmReaction.emoji)
)
let timestampAlreadyRead: Bool = dependencies.mutate(cache: .libSession) { cache in
cache.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: decodedMessage.sentTimestampMs,
openGroupUrlInfo: openGroupUrlInfo
)
}
// Don't notify if the reaction was added before the lastest read timestamp for
// the conversation or the reaction is for the sender's own message
if
!suppressNotifications &&
decodedMessage.sender != userSessionId &&
!timestampAlreadyRead &&
vmReaction.publicKey != decodedMessage.sender.hexString
{
try? dependencies[singleton: .notificationsManager].notifyUser(
cat: .messageReceiver,
message: message,
threadId: thread.id,
threadVariant: thread.variant,
interactionIdentifier: (message.serverHash ?? "\(interactionId)"),
interactionVariant: .standardIncoming,
attachmentDescriptionInfo: nil,
openGroupUrlInfo: openGroupUrlInfo,
applicationState: (isMainAppActive ? .active : .background),
extensionBaseUnreadCount: nil,
currentUserSessionIds: currentUserSessionIds,
displayNameRetriever: { sessionId, _ in
Profile.displayNameNoFallback(
db,
id: sessionId,
threadVariant: thread.variant
)
},
groupNameRetriever: { threadId, threadVariant in
switch threadVariant {
case .group:
let groupId: SessionId = SessionId(.group, hex: threadId)
return dependencies.mutate(cache: .libSession) { cache in
cache.groupName(groupSessionId: groupId)
}
case .community:
return try? OpenGroup
.select(.name)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
default: return nil
}
},
shouldShowForMessageRequest: { false }
)
}
case .remove:
let rowIds: [Int64] = try Reaction
.select(Column.rowID)
.filter(Reaction.Columns.interactionId == interactionId)
.filter(Reaction.Columns.authorId == decodedMessage.sender.hexString)
.filter(Reaction.Columns.emoji == vmReaction.emoji)
.asRequest(of: Int64.self)
.fetchAll(db)
try Reaction
.filter(Reaction.Columns.interactionId == interactionId)
.filter(Reaction.Columns.authorId == decodedMessage.sender.hexString)
.filter(Reaction.Columns.emoji == vmReaction.emoji)
.deleteAll(db)
// Notify of reaction event
rowIds.forEach {
db.addReactionEvent(
id: $0,
messageId: interactionId,
change: .removed(vmReaction.emoji)
)
}
}
return interactionId
}
private static func updateRecipientAndReadStatesForOutgoingInteraction(
_ db: ObservingDatabase,
thread: SessionThread,
interactionId: Int64,
messageSentTimestampMs: UInt64,
variant: Interaction.Variant,
syncTarget: String?,
using dependencies: Dependencies
) throws {
guard variant == .standardOutgoing else { return }
// Immediately update any existing outgoing message 'State' records to be 'sent' (can
// also remove the failure text as it's redundant if the message is in the sent state)
if (try? Interaction.select(.state).filter(id: interactionId).asRequest(of: Interaction.State.self).fetchOne(db)) != .sent {
_ = try? Interaction
.filter(id: interactionId)
.updateAll(
db,
Interaction.Columns.state.set(to: Interaction.State.sent),
Interaction.Columns.mostRecentFailureText.set(to: nil)
)
db.addMessageEvent(id: interactionId, threadId: thread.id, type: .updated(.state(.sent)))
}
// For outgoing messages mark all older interactions as read (the user should have seen
// them if they send a message - also avoids a situation where the user has "phantom"
// unread messages that they need to scroll back to before they become marked as read)
try Interaction.markAsRead(
db,
interactionId: interactionId,
threadId: thread.id,
threadVariant: thread.variant,
includingOlder: true,
trySendReadReceipt: false,
using: dependencies
)
// Process any PendingReadReceipt values
let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt
.filter(PendingReadReceipt.Columns.threadId == thread.id)
.filter(PendingReadReceipt.Columns.interactionTimestampMs == messageSentTimestampMs)
.fetchOne(db)
if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt {
try Interaction.markAsRecipientRead(
db,
threadId: thread.id,
timestampMsValues: [pendingReadReceipt.interactionTimestampMs],
readTimestampMs: pendingReadReceipt.readTimestampMs,
using: dependencies
)
_ = try pendingReadReceipt.delete(db)
}
}
private static func processMessageBody(
_ text: String?,
decodedMessage: DecodedMessage,
threadVariant: SessionThread.Variant,
dependencies: Dependencies
) -> String? {
guard let text: String = text else { return nil }
/// Extract the features used for the message
let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures(for: text)
let proStatus: SessionPro.DecodedStatus? = dependencies[singleton: .sessionProManager].proStatus(
for: decodedMessage.decodedPro?.proProof,
verifyPubkey: {
switch threadVariant {
case .community: return Array(Data(hex: Network.SessionPro.serverEdPublicKey))
default: return decodedMessage.senderEd25519Pubkey
}
}(),
atTimestampMs: decodedMessage.sentTimestampMs
)
/// Check if the message is too long
guard
info.status == .exceedsCharacterLimit || (
proStatus != .valid &&
info.features.contains(.largerCharacterLimit)
)
else { return text }
// FIXME: Replace this with a libSession-based truncation solution
let utf16View = text.utf16
let characterLimit: Int = (proStatus == .valid ?
SessionPro.ProCharacterLimit :
SessionPro.CharacterLimit
)
guard utf16View.count > characterLimit else { return text }
// Get the index at the maxUnits position in UTF16
let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: characterLimit)
// Try converting that UTF16 index back to a String.Index
if let endIndex = String.Index(endUTF16Index, within: text) {
return String(text[..<endIndex])
} else {
// Fallback: safely step back until there is a valid boundary
var adjustedIndex = endUTF16Index
while adjustedIndex > utf16View.startIndex {
adjustedIndex = utf16View.index(before: adjustedIndex)
if let validIndex = String.Index(adjustedIndex, within: text) {
return String(text[..<validIndex])
}
}
return text // If all else fails, return original string
}
}
}