mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
The `SessionThreadViewModel` has been renamed to `ConversationInfoViewModel` and is now a simple struct (as opposed to a FetchableRecord with a handful of behemoth database queries) The `ConversationInfoViewModel` and `MessageViewModel` now get initialised with a `ConversationDataCache` which is an object that holds a bunch of [ID -> Data] stores for various types that are needed to construct the aforementioned view models. The `ConversationDataCache` will generally be populated via the `ConversationDataHelper` which, when combined with our `ObservableEvent` system, is where the majority of the work for this refactoring has gone. This helper essentially has 4 parts: - `determineFetchRequirements`: Reviews any events which have come through and determines whether anything needs to be fetched from the database - `applyNonDatabaseEvents`: Updates the cached data directly based on delta information included in the `ObservableEvent` instances - `fetchFromDatabase`: Fetches any data from the database based on the info returned from `determineFetchRequirements`, this function will also resolve any dependant data that newly fetched objects need if they aren't already in the cache - `fetchFromLibSession`: Retrieves requested data from libSession Using the above setup all screens which contain conversation or message data now use these two view models, and can share the state of the view models between each other to reduce the need to refetch data when entering a new screen Some additional changes included due to the above are: • No longer need to pass `displayNameRetriever` throughout • Removed deprecated display name retrieval functions • Removed some duplicate code • Removed some synchronous database query hacks we needed until refactoring had been completed • Refactored mentions to be handled by tags • Refactored and optimised the searching logic (also added some caching) • Refactored the ThreadSettingsViewModel to use the new ObservationBuilder system • Refactored the ThreadPickerViewModel to use the new ObservationBuilder system • Refactored the HomeViewModel to retrieve data using the `ConversationDataHelper` • Refactored the ConversationViewModel to retrieve data using the `ConversationDataHelper` • Fixed an issue where communities weren't getting the correct "joined" timestamp
512 lines
23 KiB
Swift
512 lines
23 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import AVFAudio
|
|
import GRDB
|
|
import WebRTC
|
|
import SessionUtilitiesKit
|
|
import SessionNetworkingKit
|
|
|
|
// MARK: - Log.Category
|
|
|
|
public extension Log.Category {
|
|
static let calls: Log.Category = .create("Calls", defaultLevel: .info)
|
|
}
|
|
|
|
// MARK: - MessageReceiver
|
|
|
|
extension MessageReceiver {
|
|
public static func handleCallMessage(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
message: CallMessage,
|
|
decodedMessage: DecodedMessage,
|
|
suppressNotifications: Bool,
|
|
using dependencies: Dependencies
|
|
) throws -> InsertedInteractionInfo? {
|
|
// Only support calls from contact threads
|
|
guard threadVariant == .contact else {
|
|
throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations")
|
|
}
|
|
|
|
switch (message.kind, message.state) {
|
|
case (.preOffer, _):
|
|
return try MessageReceiver.handleNewCallMessage(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
message: message,
|
|
decodedMessage: decodedMessage,
|
|
suppressNotifications: suppressNotifications,
|
|
using: dependencies
|
|
)
|
|
|
|
case (.offer, _): MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies)
|
|
case (.answer, _): MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies)
|
|
case (.provisionalAnswer, _): break // TODO: [CALLS] Implement
|
|
|
|
case (.iceCandidates(let sdpMLineIndexes, let sdpMids), _):
|
|
dependencies[singleton: .callManager].handleICECandidates(
|
|
message: message,
|
|
sdpMLineIndexes: sdpMLineIndexes,
|
|
sdpMids: sdpMids
|
|
)
|
|
|
|
case (.endCall, .missed):
|
|
return try MessageReceiver.handleIncomingCallOfferInBusyState(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
message: message,
|
|
decodedMessage: decodedMessage,
|
|
suppressNotifications: suppressNotifications,
|
|
using: dependencies
|
|
)
|
|
|
|
case (.endCall, _): MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Specific Handling
|
|
|
|
private static func handleNewCallMessage(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
message: CallMessage,
|
|
decodedMessage: DecodedMessage,
|
|
suppressNotifications: Bool,
|
|
using dependencies: Dependencies
|
|
) throws -> InsertedInteractionInfo? {
|
|
Log.info(.calls, "Received pre-offer message with uuid: \(message.uuid).")
|
|
|
|
// 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]
|
|
|
|
// It is enough just ignoring the pre offers, other call messages
|
|
// for this call would be dropped because of no Session call instance
|
|
guard
|
|
dependencies[singleton: .appContext].isMainApp,
|
|
dependencies.mutate(cache: .libSession, { cache in
|
|
!cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant)
|
|
})
|
|
else { throw MessageError.missingRequiredField }
|
|
guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else {
|
|
// Add missed call message for call offer messages from more than one minute
|
|
Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)")
|
|
if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id {
|
|
let thread: SessionThread = try SessionThread.upsert(
|
|
db,
|
|
id: decodedMessage.sender.hexString,
|
|
variant: .contact,
|
|
values: .existingOrDefault,
|
|
using: dependencies
|
|
)
|
|
|
|
if !suppressNotifications && !interaction.wasRead {
|
|
/// Update the `CallMessage.state` value so the correct notification logic can occur
|
|
message.state = .missed
|
|
|
|
try? dependencies[singleton: .notificationsManager].notifyUser(
|
|
cat: .messageReceiver,
|
|
message: message,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"),
|
|
interactionVariant: interaction.variant,
|
|
attachmentDescriptionInfo: nil,
|
|
openGroupUrlInfo: nil,
|
|
applicationState: (isMainAppActive ? .active : .background),
|
|
extensionBaseUnreadCount: nil,
|
|
currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString],
|
|
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 }
|
|
)
|
|
}
|
|
|
|
return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
guard dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) && Permissions.microphone == .granted else {
|
|
let state: CallMessage.MessageInfo.State = (dependencies.mutate(cache: .libSession) { cache in
|
|
(cache.get(.areCallsEnabled) ? .permissionDeniedMicrophone : .permissionDenied)
|
|
})
|
|
|
|
Log.info(.calls, "Microphone permission is \(AVAudioSession.sharedInstance().recordPermission)")
|
|
|
|
if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id {
|
|
let thread: SessionThread = try SessionThread.upsert(
|
|
db,
|
|
id: decodedMessage.sender.hexString,
|
|
variant: .contact,
|
|
values: .existingOrDefault,
|
|
using: dependencies
|
|
)
|
|
|
|
if !suppressNotifications && !interaction.wasRead {
|
|
/// Update the `CallMessage.state` value so the correct notification logic can occur
|
|
message.state = state
|
|
|
|
try? dependencies[singleton: .notificationsManager].notifyUser(
|
|
cat: .messageReceiver,
|
|
message: message,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"),
|
|
interactionVariant: interaction.variant,
|
|
attachmentDescriptionInfo: nil,
|
|
openGroupUrlInfo: nil,
|
|
applicationState: (isMainAppActive ? .active : .background),
|
|
extensionBaseUnreadCount: nil,
|
|
currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString],
|
|
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 }
|
|
)
|
|
}
|
|
|
|
// Trigger the missed call UI if needed
|
|
NotificationCenter.default.post(
|
|
name: .missedCall,
|
|
object: nil,
|
|
userInfo: [ Notification.Key.senderId.rawValue: decodedMessage.sender.hexString ]
|
|
)
|
|
return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// If we are already on a call that is different from the current one then we are in a busy state
|
|
guard
|
|
dependencies[singleton: .callManager].currentCall == nil ||
|
|
dependencies[singleton: .callManager].currentCall?.uuid == message.uuid
|
|
else {
|
|
return try handleIncomingCallOfferInBusyState(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
message: message,
|
|
decodedMessage: decodedMessage,
|
|
suppressNotifications: suppressNotifications,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// Insert the call info message for the message (this needs to happen whether it's a new call or an existing call since the PN
|
|
/// extension will no longer insert this itself)
|
|
let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
for: message,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Ignore pre offer message after the same call instance has been generated
|
|
guard dependencies[singleton: .callManager].currentCall == nil else {
|
|
Log.info(.calls, "Ignoring pre-offer message for call[\(message.uuid)] instance because it is already active.")
|
|
return interaction.map { interaction in
|
|
interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) }
|
|
}
|
|
}
|
|
|
|
/// Handle UI for the new call
|
|
dependencies[singleton: .callManager].showCallUIForCall(
|
|
caller: decodedMessage.sender.hexString,
|
|
uuid: message.uuid,
|
|
mode: .answer,
|
|
interactionId: interaction?.id
|
|
)
|
|
return interaction.map { interaction in
|
|
interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) }
|
|
}
|
|
}
|
|
|
|
private static func handleOfferCallMessage(_ db: ObservingDatabase, message: CallMessage, using dependencies: Dependencies) {
|
|
Log.info(.calls, "Received offer message.")
|
|
|
|
// Ensure we have a call manager before continuing
|
|
guard
|
|
let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
|
|
currentCall.uuid == message.uuid,
|
|
let sdp: String = message.sdps.first
|
|
else { return }
|
|
|
|
let sdpDescription: RTCSessionDescription = RTCSessionDescription(type: .offer, sdp: sdp)
|
|
currentCall.didReceiveRemoteSDP(sdp: sdpDescription)
|
|
}
|
|
|
|
private static func handleAnswerCallMessage(
|
|
_ db: ObservingDatabase,
|
|
message: CallMessage,
|
|
using dependencies: Dependencies
|
|
) {
|
|
Log.info(.calls, "Received answer message.")
|
|
|
|
guard
|
|
dependencies[singleton: .callManager].currentWebRTCSessionMatches(callId: message.uuid),
|
|
var currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
|
|
currentCall.uuid == message.uuid,
|
|
let sender: String = message.sender
|
|
else { return }
|
|
|
|
guard sender != dependencies[cache: .general].sessionId.hexString else {
|
|
guard currentCall.mode == .answer && !currentCall.hasStartedConnecting else { return }
|
|
|
|
Task { @MainActor [callManager = dependencies[singleton: .callManager]] in
|
|
callManager.dismissAllCallUI()
|
|
}
|
|
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .answeredElsewhere)
|
|
return
|
|
}
|
|
guard let sdp: String = message.sdps.first else { return }
|
|
|
|
let sdpDescription: RTCSessionDescription = RTCSessionDescription(type: .answer, sdp: sdp)
|
|
currentCall.hasStartedConnecting = true
|
|
currentCall.didReceiveRemoteSDP(sdp: sdpDescription)
|
|
|
|
Task { @MainActor [callManager = dependencies[singleton: .callManager]] in
|
|
callManager.handleAnswerMessage(message)
|
|
}
|
|
}
|
|
|
|
private static func handleEndCallMessage(
|
|
_ db: ObservingDatabase,
|
|
message: CallMessage,
|
|
using dependencies: Dependencies
|
|
) {
|
|
Log.info(.calls, "Received end call message.")
|
|
|
|
guard
|
|
dependencies[singleton: .callManager].currentWebRTCSessionMatches(callId: message.uuid),
|
|
let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
|
|
currentCall.uuid == message.uuid,
|
|
!currentCall.hasEnded,
|
|
let sender: String = message.sender
|
|
else { return }
|
|
|
|
Task { @MainActor [callManager = dependencies[singleton: .callManager]] in
|
|
callManager.dismissAllCallUI()
|
|
}
|
|
|
|
dependencies[singleton: .callManager].reportCurrentCallEnded(
|
|
reason: (sender == dependencies[cache: .general].sessionId.hexString ?
|
|
.declinedElsewhere :
|
|
.remoteEnded
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
public static func handleIncomingCallOfferInBusyState(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
message: CallMessage,
|
|
decodedMessage: DecodedMessage,
|
|
suppressNotifications: Bool,
|
|
using dependencies: Dependencies
|
|
) throws -> InsertedInteractionInfo? {
|
|
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed)
|
|
|
|
guard
|
|
let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo),
|
|
dependencies.mutate(cache: .libSession, { cache in
|
|
!cache.isMessageRequest(threadId: decodedMessage.sender.hexString, threadVariant: threadVariant)
|
|
})
|
|
else { throw MessageError.missingRequiredField }
|
|
|
|
let interaction: Interaction = try Interaction(
|
|
serverHash: message.serverHash,
|
|
messageUuid: message.uuid,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
authorId: decodedMessage.sender.hexString,
|
|
variant: .infoCall,
|
|
body: String(data: messageInfoData, encoding: .utf8),
|
|
timestampMs: Int64(decodedMessage.sentTimestampMs),
|
|
wasRead: dependencies.mutate(cache: .libSession) { cache in
|
|
cache.timestampAlreadyRead(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
timestampMs: decodedMessage.sentTimestampMs,
|
|
openGroupUrlInfo: nil
|
|
)
|
|
},
|
|
expiresInSeconds: message.expiresInSeconds,
|
|
expiresStartedAtMs: message.expiresStartedAtMs,
|
|
using: dependencies
|
|
)
|
|
.inserted(db)
|
|
|
|
/// If we are suppressing notifications then we are loading in messages that were cached by the extensions, in which case it's
|
|
/// an old message so we would have already sent the response (all we would have needed to do in this case was save the
|
|
/// `interaction` above to the database)
|
|
if !suppressNotifications {
|
|
Log.info(.calls, "Sending end call message because there is an ongoing call.")
|
|
|
|
try sendIncomingCallOfferInBusyStateResponse(
|
|
threadId: threadId,
|
|
message: message,
|
|
disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration
|
|
.fetchOne(db, id: threadId),
|
|
authMethod: try Authentication.with(swarmPublicKey: threadId, using: dependencies),
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
)
|
|
.send(using: dependencies)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.sinkUntilComplete()
|
|
}
|
|
|
|
return interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) }
|
|
}
|
|
|
|
public static func sendIncomingCallOfferInBusyStateResponse(
|
|
threadId: String,
|
|
message: CallMessage,
|
|
disappearingMessagesConfiguration: DisappearingMessagesConfiguration?,
|
|
authMethod: AuthenticationMethod,
|
|
onEvent: ((MessageSender.Event) -> Void)?,
|
|
using dependencies: Dependencies
|
|
) throws -> Network.PreparedRequest<Message> {
|
|
return try MessageSender.preparedSend(
|
|
message: CallMessage(
|
|
uuid: message.uuid,
|
|
kind: .endCall,
|
|
sdps: [],
|
|
sentTimestampMs: nil // Explicitly nil as it's a separate message from above
|
|
)
|
|
.with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()),
|
|
to: .contact(publicKey: threadId),
|
|
namespace: .default,
|
|
interactionId: nil, // Explicitly nil as it's a separate message from above
|
|
attachments: nil,
|
|
authMethod: authMethod,
|
|
onEvent: onEvent,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
@discardableResult public static func insertCallInfoMessage(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
for message: CallMessage,
|
|
state: CallMessage.MessageInfo.State? = nil,
|
|
using dependencies: Dependencies
|
|
) throws -> Interaction? {
|
|
guard (
|
|
try? Interaction
|
|
.filter(Interaction.Columns.variant == Interaction.Variant.infoCall)
|
|
.filter(Interaction.Columns.messageUuid == message.uuid)
|
|
.isEmpty(db)
|
|
).defaulting(to: false)
|
|
else { throw MessageError.duplicatedCall }
|
|
|
|
guard
|
|
let sender: String = message.sender,
|
|
dependencies.mutate(cache: .libSession, { cache in
|
|
!cache.isMessageRequest(threadId: sender, threadVariant: threadVariant)
|
|
})
|
|
else { return nil }
|
|
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(
|
|
state: state.defaulting(
|
|
to: (sender == userSessionId.hexString ?
|
|
.outgoing :
|
|
.incoming
|
|
)
|
|
)
|
|
)
|
|
let timestampMs: Int64 = (
|
|
message.sentTimestampMs.map { Int64($0) } ??
|
|
dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
)
|
|
|
|
guard let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo) else {
|
|
return nil
|
|
}
|
|
|
|
return try Interaction(
|
|
serverHash: message.serverHash,
|
|
messageUuid: message.uuid,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
authorId: sender,
|
|
variant: .infoCall,
|
|
body: String(data: messageInfoData, encoding: .utf8),
|
|
timestampMs: timestampMs,
|
|
wasRead: dependencies.mutate(cache: .libSession) { cache in
|
|
cache.timestampAlreadyRead(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
timestampMs: UInt64(timestampMs),
|
|
openGroupUrlInfo: nil
|
|
)
|
|
},
|
|
expiresInSeconds: message.expiresInSeconds,
|
|
expiresStartedAtMs: message.expiresStartedAtMs,
|
|
using: dependencies
|
|
)
|
|
.inserted(db)
|
|
}
|
|
}
|