session-ios/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift
Morgan Pretty eb0118ac10 Fixed a few more bugs and tweaked attachment download logic
Updated the code to only auto-start attachment downloads when a user opens a conversation (and only for the current page of messages)
Updated the GarbageCollectionJob to default to handling all cases (instead of requiring the cases to be defined) - this means we can add future cases without having to recreate the default job
Added logic to remove approved blinded contact records as part of the GarbageCollectionJob
Added code to better handle "invalid" attachments when migrating
Added a mechanism to retrieve the details for currently running jobs (ie. allows us to check for duplicate concurrent jobs)
Resolved the remaining TODOs in the GRDB migration code
Cleaned up DB update logic to update only the targeted columns
Fixed a bug due to a typo in a localised string
Fixed a bug where link previews without images or with custom copy weren't being processed as link previews
Fixed a bug where Open Groups could display with an empty name value
2022-07-01 12:52:41 +10:00

311 lines
13 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
import SignalCoreKit
import SessionUtilitiesKit
extension MessageReceiver {
@discardableResult public static func handleVisibleMessage(
_ db: Database,
message: VisibleMessage,
associatedWithProto proto: SNProtoContent,
openGroupId: String?,
isBackgroundPoll: Bool,
dependencies: Dependencies = Dependencies()
) throws -> Int64 {
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 messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000)
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false)
// 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 {
var contactProfileKey: OWSAES256Key? = nil
if let profileKey = profile.profileKey { contactProfileKey = OWSAES256Key(data: profileKey) }
try MessageReceiver.updateProfileIfNeeded(
db,
publicKey: sender,
name: profile.displayName,
profilePictureUrl: profile.profilePictureUrl,
profileKey: contactProfileKey,
sentTimestamp: messageSentTimestamp
)
}
// Get or create thread
guard let threadInfo: (id: String, variant: SessionThread.Variant) = MessageReceiver.threadInfo(db, message: message, openGroupId: openGroupId) else {
throw MessageReceiverError.noThread
}
// Store the message variant so we can run variant-specific behaviours
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant)
let variant: Interaction.Variant = {
guard
let openGroupId: String = openGroupId,
let senderSessionId: SessionId = SessionId(from: sender),
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId)
else {
return (sender == currentUserPublicKey ?
.standardOutgoing :
.standardIncoming
)
}
// Need to check if the blinded id matches for open groups
switch senderSessionId.prefix {
case .blinded:
let sodium: Sodium = Sodium()
guard
let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db),
let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(
serverPublicKey: openGroup.publicKey,
edKeyPair: userEdKeyPair,
genericHash: sodium.genericHash
)
else { return .standardIncoming }
return (sender == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString ?
.standardOutgoing :
.standardIncoming
)
case .standard, .unblinded:
return (sender == currentUserPublicKey ?
.standardOutgoing :
.standardIncoming
)
}
}()
// Retrieve the disappearing messages config to set the 'expiresInSeconds' value
// accoring to the config
let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db))
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id))
// 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
do {
interaction = try Interaction(
serverHash: message.serverHash, // Keep track of server hash
threadId: thread.id,
authorId: sender,
variant: variant,
body: message.text,
timestampMs: Int64(messageSentTimestamp * 1000),
wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read
hasMention: Interaction.isUserMentioned(
db,
threadId: thread.id,
body: message.text,
quoteAuthorId: dataMessage.quote?.author
),
// Note: Ensure we don't ever expire open group messages
expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ?
disappearingMessagesConfiguration.durationSeconds :
nil
),
expiresStartedAtMs: nil,
// 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) },
openGroupWhisperMods: (message.recipient?.contains(".mods") == true),
openGroupWhisperTo: {
guard
let recipientParts: [String] = message.recipient?.components(separatedBy: "."),
recipientParts.count >= 3 // 'server.roomToken.whisperTo.whisperMods'
else { return nil }
return recipientParts[2]
}()
).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 up update the recipient and read states for the
// message (even if we don't need to do anything else)
try updateRecipientAndReadStates(
db,
thread: thread,
interactionId: existingInteractionId,
variant: variant,
syncTarget: message.syncTarget
)
default: break
}
throw error
}
guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave }
// Update and recipient and read states as needed
try updateRecipientAndReadStates(
db,
thread: thread,
interactionId: interactionId,
variant: variant,
syncTarget: message.syncTarget
)
// 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.saved(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(
db,
proto: dataMessage,
interactionId: interactionId,
thread: thread
)?.insert(db)
// Parse link preview if needed
try? LinkPreview(
db,
proto: dataMessage,
body: message.text,
sentTimestampMs: (messageSentTimestamp * 1000)
)?.save(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
).save(db)
}
// Cancel any typing indicators if needed
if isMainAppActive {
TypingIndicators.didStopTyping(db, 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
if thread.variant == .contact {
try MessageReceiver.updateContactApprovalStatusIfNeeded(
db,
senderSessionId: sender,
threadId: thread.id,
forceConfigSync: false
)
}
// Notify the user if needed
guard variant == .standardIncoming else { return interactionId }
// Use the same identifier for notifications when in backgroud polling to prevent spam
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
for: interaction,
in: thread,
isBackgroundPoll: isBackgroundPoll
)
return interactionId
}
private static func updateRecipientAndReadStates(
_ db: Database,
thread: SessionThread,
interactionId: Int64,
variant: Interaction.Variant,
syncTarget: String?
) throws {
guard variant == .standardOutgoing else { return }
if let syncTarget: String = syncTarget {
try RecipientState(
interactionId: interactionId,
recipientId: syncTarget,
state: .sent
).save(db)
}
else if thread.variant == .closedGroup {
try GroupMember
.filter(GroupMember.Columns.groupId == thread.id)
.fetchAll(db)
.forEach { member in
try RecipientState(
interactionId: interactionId,
recipientId: member.profileId,
state: .sent
).save(db)
}
}
// 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,
includingOlder: true,
trySendReadReceipt: true
)
}
}