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
537 lines
23 KiB
Swift
537 lines
23 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import WebRTC
|
|
import SessionNetworkingKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
public protocol WebRTCSessionDelegate: AnyObject {
|
|
var videoCapturer: RTCVideoCapturer { get }
|
|
|
|
func webRTCIsConnected()
|
|
func isRemoteVideoDidChange(isEnabled: Bool)
|
|
func sendingIceCandidates()
|
|
func iceCandidateDidSend()
|
|
func iceCandidateDidReceive()
|
|
func dataChannelDidOpen()
|
|
func didReceiveHangUpSignal()
|
|
func reconnectIfNeeded()
|
|
}
|
|
|
|
/// See https://webrtc.org/getting-started/overview for more information.
|
|
public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate {
|
|
private let dependencies: Dependencies
|
|
public weak var delegate: WebRTCSessionDelegate?
|
|
public let uuid: String
|
|
private let contactSessionId: String
|
|
private var queuedICECandidates: [RTCIceCandidate] = []
|
|
private var iceCandidateSendTimer: Timer?
|
|
|
|
private lazy var defaultICEServer: TurnServerInfo? = {
|
|
guard
|
|
let url = Bundle.main.url(forResource: "Session-Turn-Server", withExtension: nil), // stringlint:ignroe
|
|
let data = try? Data(contentsOf: url),
|
|
let json = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON
|
|
else { return nil }
|
|
return TurnServerInfo(attributes: json, random: 2)
|
|
}()
|
|
|
|
internal lazy var factory: RTCPeerConnectionFactory = {
|
|
RTCInitializeSSL()
|
|
let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
|
|
let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
|
|
return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory)
|
|
}()
|
|
|
|
/// Represents a WebRTC connection between the user and a remote peer. Provides methods to connect to a
|
|
/// remote peer, maintain and monitor the connection, and close the connection once it's no longer needed.
|
|
internal lazy var peerConnection: RTCPeerConnection? = {
|
|
let configuration = RTCConfiguration()
|
|
if let defaultICEServer = defaultICEServer {
|
|
configuration.iceServers = [ RTCIceServer(urlStrings: defaultICEServer.urls, username: defaultICEServer.username, credential: defaultICEServer.password) ]
|
|
}
|
|
configuration.sdpSemantics = .unifiedPlan
|
|
let constraints = RTCMediaConstraints(mandatoryConstraints: [:], optionalConstraints: [:])
|
|
return factory.peerConnection(with: configuration, constraints: constraints, delegate: self)
|
|
}()
|
|
|
|
// Audio
|
|
internal lazy var audioSource: RTCAudioSource = {
|
|
let constraints = RTCMediaConstraints(mandatoryConstraints: [:], optionalConstraints: [:])
|
|
return factory.audioSource(with: constraints)
|
|
}()
|
|
|
|
internal lazy var audioTrack: RTCAudioTrack = {
|
|
return factory.audioTrack(with: audioSource, trackId: Self.Constants.audio_track_id)
|
|
}()
|
|
|
|
// Video
|
|
public lazy var localVideoSource: RTCVideoSource = {
|
|
let result = factory.videoSource()
|
|
result.adaptOutputFormat(toWidth: 360, height: 780, fps: 30)
|
|
return result
|
|
}()
|
|
|
|
internal lazy var localVideoTrack: RTCVideoTrack = {
|
|
return factory.videoTrack(with: localVideoSource, trackId: Self.Constants.local_video_track_id)
|
|
}()
|
|
|
|
internal lazy var remoteVideoTrack: RTCVideoTrack? = {
|
|
return peerConnection?.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack
|
|
}()
|
|
|
|
// Data Channel
|
|
internal var dataChannel: RTCDataChannel?
|
|
|
|
// MARK: - Error
|
|
|
|
public enum WebRTCSessionError: LocalizedError {
|
|
case noThread
|
|
|
|
// stringlint:ignore_contents
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noThread: return "Couldn't find thread for contact."
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Initialization
|
|
public static var current: WebRTCSession?
|
|
|
|
public init(for contactSessionId: String, with uuid: String, using dependencies: Dependencies) {
|
|
RTCAudioSession.sharedInstance().useManualAudio = true
|
|
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
|
|
self.contactSessionId = contactSessionId
|
|
self.uuid = uuid
|
|
self.dependencies = dependencies
|
|
|
|
super.init()
|
|
Log.info(.calls, "ICE Severs: \(defaultICEServer?.urls ?? [])")
|
|
|
|
let mediaStreamTrackIDS = [Self.Constants.media_stream_track_id]
|
|
|
|
peerConnection?.add(audioTrack, streamIds: mediaStreamTrackIDS)
|
|
peerConnection?.add(localVideoTrack, streamIds: mediaStreamTrackIDS)
|
|
|
|
// Configure audio session
|
|
configureAudioSession()
|
|
|
|
// Data channel
|
|
if let dataChannel = createDataChannel() {
|
|
dataChannel.delegate = self
|
|
self.dataChannel = dataChannel
|
|
}
|
|
}
|
|
|
|
// MARK: - Signaling
|
|
|
|
public func sendPreOffer(
|
|
message: CallMessage,
|
|
threadId: String,
|
|
interactionId: Int64?,
|
|
authMethod: AuthenticationMethod
|
|
) throws -> AnyPublisher<Void, Error> {
|
|
Log.info(.calls, "Sending pre-offer message.")
|
|
|
|
return try MessageSender
|
|
.preparedSend(
|
|
message: message,
|
|
to: .contact(publicKey: threadId),
|
|
namespace: .default,
|
|
interactionId: interactionId,
|
|
attachments: nil,
|
|
authMethod: authMethod,
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
)
|
|
.send(using: dependencies)
|
|
.map { _ in () }
|
|
.handleEvents(receiveOutput: { _ in Log.info(.calls, "Pre-offer message has been sent.") })
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
public func sendOffer(
|
|
to thread: SessionThread,
|
|
isRestartingICEConnection: Bool = false
|
|
) -> AnyPublisher<Void, Error> {
|
|
Log.info(.calls, "Sending offer message.")
|
|
let uuid: String = self.uuid
|
|
let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection)
|
|
|
|
return Deferred { [weak self, dependencies] in
|
|
Future<Void, Error> { resolver in
|
|
self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in
|
|
guard error == nil else { return }
|
|
|
|
guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else {
|
|
preconditionFailure()
|
|
}
|
|
|
|
self?.peerConnection?.setLocalDescription(sdp) { error in
|
|
if let error = error {
|
|
Log.error(.calls, "Couldn't initiate call due to error: \(error).")
|
|
resolver(Result.failure(error))
|
|
return
|
|
}
|
|
}
|
|
|
|
dependencies[singleton: .storage]
|
|
.writePublisher { db -> DisappearingMessagesConfiguration? in
|
|
try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.tryFlatMap { disappearingMessagesConfiguration in
|
|
try MessageSender.preparedSend(
|
|
message: CallMessage(
|
|
uuid: uuid,
|
|
kind: .offer,
|
|
sdps: [ sdp.sdp ],
|
|
sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
)
|
|
.with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()),
|
|
to: .contact(publicKey: thread.id),
|
|
namespace: .default,
|
|
interactionId: nil,
|
|
attachments: nil,
|
|
authMethod: try Authentication.with(
|
|
swarmPublicKey: thread.id,
|
|
using: dependencies
|
|
),
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
).send(using: dependencies)
|
|
}
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: resolver(Result.success(()))
|
|
case .failure(let error): resolver(Result.failure(error))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
public func sendAnswer(to sessionId: String) -> AnyPublisher<Void, Error> {
|
|
Log.info(.calls, "Sending answer message.")
|
|
let uuid: String = self.uuid
|
|
let mediaConstraints: RTCMediaConstraints = mediaConstraints(false)
|
|
|
|
return dependencies[singleton: .storage]
|
|
.readPublisher { db -> DisappearingMessagesConfiguration? in
|
|
/// Ensure a thread exists for the `sessionId` and that it's a `contact` thread
|
|
guard
|
|
SessionThread
|
|
.filter(id: sessionId)
|
|
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
|
|
.isNotEmpty(db)
|
|
else { throw WebRTCSessionError.noThread }
|
|
|
|
return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId)
|
|
}
|
|
.flatMap { [weak self, dependencies] disappearingMessagesConfiguration in
|
|
Future<Void, Error> { resolver in
|
|
self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in
|
|
if let error = error {
|
|
resolver(Result.failure(error))
|
|
return
|
|
}
|
|
|
|
guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else {
|
|
preconditionFailure()
|
|
}
|
|
|
|
self?.peerConnection?.setLocalDescription(sdp) { error in
|
|
if let error = error {
|
|
Log.error(.calls, "Couldn't accept call due to error: \(error).")
|
|
return resolver(Result.failure(error))
|
|
}
|
|
}
|
|
|
|
Result {
|
|
try MessageSender.preparedSend(
|
|
message: CallMessage(
|
|
uuid: uuid,
|
|
kind: .answer,
|
|
sdps: [ sdp.sdp ]
|
|
)
|
|
.with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()),
|
|
to: .contact(publicKey: sessionId),
|
|
namespace: .default,
|
|
interactionId: nil,
|
|
attachments: nil,
|
|
authMethod: try Authentication.with(
|
|
swarmPublicKey: sessionId,
|
|
using: dependencies
|
|
),
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
)
|
|
}
|
|
.publisher
|
|
.flatMap { $0.send(using: dependencies) }
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: resolver(Result.success(()))
|
|
case .failure(let error): resolver(Result.failure(error))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
private func queueICECandidateForSending(_ candidate: RTCIceCandidate) {
|
|
queuedICECandidates.append(candidate)
|
|
DispatchQueue.main.async {
|
|
self.iceCandidateSendTimer?.invalidate()
|
|
self.iceCandidateSendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
|
|
self.sendICECandidates()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendICECandidates() {
|
|
self.delegate?.sendingIceCandidates()
|
|
let candidates: [RTCIceCandidate] = self.queuedICECandidates
|
|
let uuid: String = self.uuid
|
|
let contactSessionId: String = self.contactSessionId
|
|
|
|
// Empty the queue
|
|
self.queuedICECandidates.removeAll()
|
|
|
|
return dependencies[singleton: .storage]
|
|
.readPublisher { db -> DisappearingMessagesConfiguration? in
|
|
/// Ensure a thread exists for the `sessionId` and that it's a `contact` thread
|
|
guard
|
|
SessionThread
|
|
.filter(id: contactSessionId)
|
|
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
|
|
.isNotEmpty(db)
|
|
else { throw WebRTCSessionError.noThread }
|
|
|
|
return try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.tryFlatMap { [dependencies] disappearingMessagesConfiguration in
|
|
Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.")
|
|
|
|
return try MessageSender
|
|
.preparedSend(
|
|
message: CallMessage(
|
|
uuid: uuid,
|
|
kind: .iceCandidates(
|
|
sdpMLineIndexes: candidates.map { UInt32($0.sdpMLineIndex) },
|
|
sdpMids: candidates.map { $0.sdpMid! }
|
|
),
|
|
sdps: candidates.map { $0.sdp }
|
|
)
|
|
.with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()),
|
|
to: .contact(publicKey: contactSessionId),
|
|
namespace: .default,
|
|
interactionId: nil,
|
|
attachments: nil,
|
|
authMethod: try Authentication.with(
|
|
swarmPublicKey: contactSessionId,
|
|
using: dependencies
|
|
),
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
)
|
|
.send(using: dependencies)
|
|
.retry(5)
|
|
}
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self] result in
|
|
switch result {
|
|
case .finished:
|
|
Log.info(.calls, "ICE candidates sent")
|
|
self?.delegate?.iceCandidateDidSend()
|
|
case .failure(let error):
|
|
Log.error(.calls, "Error sending ICE candidates due to error: \(error)")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
public func endCall(with sessionId: String) {
|
|
return dependencies[singleton: .storage]
|
|
.readPublisher { db -> DisappearingMessagesConfiguration? in
|
|
/// Ensure a thread exists for the `sessionId` and that it's a `contact` thread
|
|
guard
|
|
SessionThread
|
|
.filter(id: sessionId)
|
|
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
|
|
.isNotEmpty(db)
|
|
else { throw WebRTCSessionError.noThread }
|
|
|
|
return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.tryFlatMap { [dependencies, uuid] disappearingMessagesConfiguration in
|
|
Log.info(.calls, "Sending end call message.")
|
|
|
|
return try MessageSender
|
|
.preparedSend(
|
|
message: CallMessage(
|
|
uuid: uuid,
|
|
kind: .endCall,
|
|
sdps: []
|
|
)
|
|
.with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()),
|
|
to: .contact(publicKey: sessionId),
|
|
namespace: .default,
|
|
interactionId: nil,
|
|
attachments: nil,
|
|
authMethod: try Authentication.with(
|
|
swarmPublicKey: sessionId,
|
|
using: dependencies
|
|
),
|
|
onEvent: MessageSender.standardEventHandling(using: dependencies),
|
|
using: dependencies
|
|
)
|
|
.send(using: dependencies)
|
|
.retry(5)
|
|
}
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished:
|
|
Log.info(.calls, "End call message sent")
|
|
case .failure(let error):
|
|
Log.error(.calls, "Error sending End call message due to error: \(error)")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
public func dropConnection() {
|
|
peerConnection?.close()
|
|
}
|
|
|
|
private func mediaConstraints(_ isRestartingICEConnection: Bool) -> RTCMediaConstraints {
|
|
var mandatory: [String:String] = [
|
|
kRTCMediaConstraintsOfferToReceiveAudio : kRTCMediaConstraintsValueTrue,
|
|
kRTCMediaConstraintsOfferToReceiveVideo : kRTCMediaConstraintsValueTrue,
|
|
]
|
|
if isRestartingICEConnection { mandatory[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue }
|
|
let optional: [String:String] = [:]
|
|
return RTCMediaConstraints(mandatoryConstraints: mandatory, optionalConstraints: optional)
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
private func correctSessionDescription(sdp: RTCSessionDescription?) -> RTCSessionDescription? {
|
|
guard let sdp = sdp else { return nil }
|
|
let cbrSdp = sdp.sdp.description.replace(regex: "(a=fmtp:111 ((?!cbr=).)*)\r?\n", with: "$1;cbr=1\r\n")
|
|
let finalSdp = cbrSdp.replace(regex: ".+urn:ietf:params:rtp-hdrext:ssrc-audio-level.*\r?\n", with: "")
|
|
return RTCSessionDescription(type: sdp.type, sdp: finalSdp)
|
|
}
|
|
|
|
// MARK: Peer connection delegate
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCSignalingState) {
|
|
Log.info(.calls, "Signaling state changed to: \(state).")
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
|
|
Log.info(.calls, "Peer connection did add stream.")
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {
|
|
Log.info(.calls, "Peer connection did remove stream.")
|
|
}
|
|
|
|
public func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {
|
|
Log.info(.calls, "Peer connection should negotiate.")
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceConnectionState) {
|
|
Log.info(.calls, "ICE connection state changed to: \(state).")
|
|
if state == .connected {
|
|
delegate?.webRTCIsConnected()
|
|
} else if state == .disconnected {
|
|
if self.peerConnection?.signalingState == .stable {
|
|
delegate?.reconnectIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceGatheringState) {
|
|
Log.info(.calls, "ICE gathering state changed to: \(state).")
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
|
|
queueICECandidateForSending(candidate)
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {
|
|
Log.info(.calls, "\(candidates.count) ICE candidate(s) removed.")
|
|
}
|
|
|
|
public func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {
|
|
Log.info(.calls, "Data channel opened.")
|
|
}
|
|
}
|
|
|
|
extension WebRTCSession {
|
|
public func configureAudioSession() {
|
|
let audioSession = RTCAudioSession.sharedInstance()
|
|
audioSession.lockForConfiguration()
|
|
do {
|
|
try audioSession.setCategory(
|
|
.playAndRecord,
|
|
mode: .videoChat,
|
|
options: [.allowBluetooth, .allowBluetoothA2DP]
|
|
)
|
|
try audioSession.setActive(true)
|
|
} catch let error {
|
|
Log.error(.calls, "Couldn't set up WebRTC audio session due to error: \(error)")
|
|
}
|
|
audioSession.unlockForConfiguration()
|
|
}
|
|
|
|
public func audioSessionDidActivate(_ audioSession: AVAudioSession) {
|
|
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
|
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
|
configureAudioSession()
|
|
}
|
|
|
|
public func audioSessionDidDeactivate(_ audioSession: AVAudioSession) {
|
|
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
|
|
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
}
|
|
|
|
public func mute() {
|
|
audioTrack.isEnabled = false
|
|
}
|
|
|
|
public func unmute() {
|
|
audioTrack.isEnabled = true
|
|
}
|
|
|
|
public func turnOffVideo() {
|
|
localVideoTrack.isEnabled = false
|
|
sendJSON([Self.Constants.video: false])
|
|
}
|
|
|
|
public func turnOnVideo() {
|
|
localVideoTrack.isEnabled = true
|
|
sendJSON([Self.Constants.video: true])
|
|
}
|
|
|
|
public func hangUp() {
|
|
sendJSON([Self.Constants.hang_up: true])
|
|
}
|
|
}
|