Files
Morgan Pretty 68964990a1 Replicating the current user profile into the database again
• Rolled back the change to stop replicating the current user profile into the database (and the various attempted fixes that came as a result)
• Added env variable handling for "shortenFileTTL"
• Made some improvements to the libSession build script and error handling
• Fixed an issue where the reupload job would always extend to the max TTL regardless of the "shorten TTL" dev setting
• Fixed an issue where setting the profile name before setting the display pic could result in the old display pic being used (even when setting to the same value - bug in libSession needs to be fixed to handle this case)
• Fixed an issue where profile updates coming from libSession wouldn't be saved correctly in some cases
2025-10-27 15:55:50 +11:00

722 lines
31 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,
serverExpirationTimestamp: TimeInterval?,
associatedWithProto proto: SNProtoContent,
suppressNotifications: Bool,
using dependencies: Dependencies
) throws -> InsertedInteractionInfo {
guard let sender: String = message.sender, let dataMessage = proto.dataMessage else {
throw MessageReceiverError.invalidMessage
}
// Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to
// seconds to maintain the accuracy)
let messageSentTimestampMs: UInt64 = message.sentTimestampMs ?? 0
let messageSentTimestamp: TimeInterval = TimeInterval(Double(messageSentTimestampMs) / 1000)
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
// Update profile if needed (want to do this regardless of whether the message exists or
// not to ensure the profile info gets sync between a users devices at every chance)
if let profile = message.profile {
try Profile.updateIfNeeded(
db,
publicKey: sender,
displayNameUpdate: .contactUpdate(profile.displayName),
displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies),
blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests),
profileUpdateTimestamp: profile.updateTimestampSeconds,
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 MessageReceiverError.noThread
}
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 MessageReceiverError.noThread
}
}
// 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(messageSentTimestamp),
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 senderSessionId: SessionId = try? SessionId(from: sender),
let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo
else {
return (sender == userSessionId.hexString ?
.standardOutgoing :
.standardIncoming
)
}
// Need to check if the blinded id matches for open groups
switch senderSessionId.prefix {
case .blinded15, .blinded25:
guard
dependencies[singleton: .crypto].verify(
.sessionId(
userSessionId.hexString,
matchesBlindedId: sender,
serverPublicKey: openGroupUrlInfo.publicKey
)
)
else { return .standardIncoming }
return .standardOutgoing
case .standard, .unblinded:
return (sender == userSessionId.hexString ?
.standardOutgoing :
.standardIncoming
)
case .group, .versionBlinded07:
Log.info(.messageReceiver, "Ignoring message with invalid sender.")
throw MessageReceiverError.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,
associatedWithProto: proto,
sender: sender,
messageSentTimestamp: messageSentTimestamp,
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: Int64(messageSentTimestamp * 1000),
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 isProMessage: Bool = dependencies.mutate(cache: .libSession, { $0.validateProProof(for: message) })
let processedMessageBody: String? = Self.truncateMessageTextIfNeeded(
message.text,
isProMessage: isProMessage,
dependencies: dependencies
)
interaction = try Interaction(
serverHash: message.serverHash, // Keep track of server hash
threadId: thread.id,
threadVariant: thread.variant,
authorId: sender,
variant: variant,
body: processedMessageBody,
timestampMs: Int64(messageSentTimestamp * 1000),
wasRead: wasRead,
hasMention: Interaction.isUserMentioned(
db,
threadId: thread.id,
body: processedMessageBody,
quoteAuthorId: dataMessage.quote?.author,
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),
isProMessage: isProMessage,
using: dependencies
).inserted(db)
}
catch {
switch error {
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE:
guard
variant == .standardOutgoing,
let existingInteractionId: Int64 = try? thread.interactions
.select(.id)
.filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000))
.filter(Interaction.Columns.variant == variant)
.filter(Interaction.Columns.authorId == sender)
.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,
messageSentTimestamp: messageSentTimestamp,
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,
messageSentTimestamp: messageSentTimestamp,
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 attachments: [Attachment] = try dataMessage.attachments
.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
try? Quote(
proto: dataMessage,
interactionId: interactionId,
thread: thread
)?.insert(db)
// Parse link preview if needed
let linkPreview: LinkPreview? = try? LinkPreview(
db,
proto: dataMessage,
sentTimestampMs: (messageSentTimestamp * 1000)
)?.upserted(db)
// 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: (messageSentTimestamp * 1000)),
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: sender))?.isTrusted ?? false)
if isContactTrusted || thread.variant != .contact {
attachments
.map { $0.id }
.appending(linkPreview?.attachmentId)
.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: sender,
threadId: thread.id,
using: dependencies
)
case .group:
try MessageReceiver.updateMemberApprovalStatusIfNeeded(
db,
senderSessionId: sender,
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,
associatedWithProto proto: SNProtoContent,
sender: String,
messageSentTimestamp: TimeInterval,
openGroupUrlInfo: LibSession.OpenGroupUrlInfo?,
currentUserSessionIds: Set<String>,
suppressNotifications: Bool,
using dependencies: Dependencies
) throws -> Int64? {
guard
let vmReaction: VisibleMessage.VMReaction = message.reaction,
proto.dataMessage?.reaction != nil
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 timestampMs: Int64 = Int64(messageSentTimestamp * 1000)
let userSessionId: SessionId = dependencies[cache: .general].sessionId
_ = try Reaction(
interactionId: interactionId,
serverHash: message.serverHash,
timestampMs: timestampMs,
authorId: sender,
emoji: vmReaction.emoji,
count: 1,
sortId: sortId
).inserted(db)
let timestampAlreadyRead: Bool = dependencies.mutate(cache: .libSession) { cache in
cache.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: timestampMs,
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 &&
sender != userSessionId.hexString &&
!timestampAlreadyRead &&
vmReaction.publicKey != sender
{
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:
try Reaction
.filter(Reaction.Columns.interactionId == interactionId)
.filter(Reaction.Columns.authorId == sender)
.filter(Reaction.Columns.emoji == vmReaction.emoji)
.deleteAll(db)
}
return interactionId
}
private static func updateRecipientAndReadStatesForOutgoingInteraction(
_ db: ObservingDatabase,
thread: SessionThread,
interactionId: Int64,
messageSentTimestamp: TimeInterval,
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 == Int64(messageSentTimestamp * 1000))
.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 truncateMessageTextIfNeeded(
_ text: String?,
isProMessage: Bool,
dependencies: Dependencies
) -> String? {
guard let text = text else { return nil }
let utf16View = text.utf16
// TODO: Remove after Session Pro is enabled
let isSessionProEnabled: Bool = (dependencies.hasSet(feature: .sessionProEnabled) && dependencies[feature: .sessionProEnabled])
let offset: Int = (isSessionProEnabled && !isProMessage) ?
LibSession.CharacterLimit :
LibSession.ProCharacterLimit
guard utf16View.count > offset else { return text }
// Get the index at the maxUnits position in UTF16
let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: offset)
// 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
}
}
}