diff --git a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift index 57c8d311a..9626453b4 100644 --- a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift +++ b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift @@ -19,12 +19,26 @@ import Foundation #if canImport(JitsiMeet) import JitsiMeet +enum JitsiServiceError: Error { + case widgetContentCreationFailed + case emptyResponse + case unknown +} + /// JitsiService enables to abstract and configure Jitsi Meet SDK @objcMembers final class JitsiService: NSObject { static let shared = JitsiService() + private enum Constants { + static let widgetIdLength = 7 + } + + private struct Route { + static let wellKnown = "/.well-known/element/jitsi" + } + // MARK: - Properties var enableCallKit: Bool = true { @@ -38,6 +52,10 @@ final class JitsiService: NSObject { } private let jitsiMeet = JitsiMeet.sharedInstance() + private var httpClient: MXHTTPClient? + private let serializationService: SerializationServiceType = SerializationService() + + private var httpClients: [String: MXHTTPClient] = [:] // MARK: - Setup @@ -59,6 +77,52 @@ final class JitsiService: NSObject { JMCallKitProxy.configureProvider(localizedName: localizedName, ringtoneSound: ringtoneName, iconTemplateImageData: iconTemplateImageData) } + // MARK: WellKnown + + @discardableResult + func getWellKnown(for jitsiServerURL: URL, completion: @escaping (Result) -> Void) -> MXHTTPOperation? { + guard let httpClient = self.httpClient(for: jitsiServerURL) else { + completion(.failure(JitsiServiceError.unknown)) + return nil + } + + return httpClient.request(withMethod: "GET", path: Route.wellKnown, parameters: nil, success: { response in + guard let response = response else { + completion(.failure(JitsiServiceError.emptyResponse)) + return + } + + do { + let jitsiWellKnown: JitsiWellKnown = try self.serializationService.deserialize(response) + completion(.success(jitsiWellKnown)) + } catch { + completion(.failure(error)) + } + }, failure: { (error) in + completion(.failure(error ?? JitsiServiceError.unknown)) + }) + } + + @discardableResult + func createJitsiWidgetContent(jitsiServerURL: URL, roomID: String, isAudioOnly: Bool, success: @escaping ([AnyHashable: Any]) -> Void, failure: @escaping ((Error) -> Void)) -> MXHTTPOperation? { + return self.getWellKnown(for: jitsiServerURL) { (result) in + switch result { + case .success(let jitsiWellKnown): + if let serverDomain = jitsiServerURL.host, let widgetContent = self.createJitsiWidgetContent(serverDomain: serverDomain, + authenticationType: jitsiWellKnown.authenticationType, + roomID: roomID, + isAudioOnly: isAudioOnly) { + success(widgetContent) + } else { + failure(JitsiServiceError.widgetContentCreationFailed) + } + case .failure(let error): + NSLog("[JitsiService] Fail to get Jitsi Well Known with error: \(error)") + failure(error) + } + } + } + // MARK: AppDelegate methods @discardableResult @@ -73,5 +137,113 @@ final class JitsiService: NSObject { func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { return self.jitsiMeet.application(application, continue: userActivity, restorationHandler: restorationHandler) } + + // MARK: - Private + + private func httpClient(for jitsiServerURL: URL) -> MXHTTPClient? { + let httpClient: MXHTTPClient? + + let baseStringURL = jitsiServerURL.absoluteString + + if let existingHttpClient = self.httpClients[baseStringURL] { + httpClient = existingHttpClient + } else if let createdHttpClient = MXHTTPClient(baseURL: baseStringURL, andOnUnrecognizedCertificateBlock: nil) { + + httpClient = createdHttpClient + self.httpClients[baseStringURL] = httpClient + } else { + httpClient = nil + } + + return httpClient + } + + private func createJitsiWidgetContent(serverDomain: String, + authenticationType: JitsiAuthenticationType?, + roomID: String, + isAudioOnly: Bool) -> [AnyHashable: Any]? { + guard MXTools.isMatrixRoomIdentifier(roomID) else { + NSLog("[JitsiService] createJitsiWidgetContent the roomID is not valid") + return nil + } + + // Create a random enough jitsi conference id + // Note: the jitsi server automatically creates conference when the conference + // id does not exist yet + let widgetSessionId = (ProcessInfo.processInfo.globallyUniqueString as NSString).substring(to: Constants.widgetIdLength).lowercased() + + let conferenceID: String + + let authenticationTypeString: String? + + if let authenticationType = authenticationType, authenticationType == .openIDTokenJWT { + + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + conferenceID = Base32Coder.encodedString(roomID, padding: false) + authenticationTypeString = authenticationType.identifier + } else { + let localRoomId = roomID.components(separatedBy: ":").first ?? "" + conferenceID = localRoomId + widgetSessionId + authenticationTypeString = nil + } + + // Build widget url + // Riot-iOS does not directly use it but extracts params from it (see `[JitsiViewController openWidget:withVideo:]`) + // This url can be used as is inside a web container (like iframe for Riot-web) + + // Build it from the riot-web app + let appUrlString = BuildSettings.applicationWebAppUrlString + + // We mix v1 and v2 param for backward compability + let v1queryStringParts = [ + "confId=\(conferenceID)", + "isAudioConf=\(isAudioOnly ? "true" : "false")", + "displayName=$matrix_display_name", + "avatarUrl=$matrix_avatar_url", + "email=$matrix_user_id" + ] + + let v1Params = v1queryStringParts.joined(separator: "&") + + var v2queryStringParts = [ + "conferenceDomain=$domain", + "conferenceId=$conferenceId", + "isAudioOnly=$isAudioOnly", + "displayName=$matrix_display_name", + "avatarUrl=$matrix_avatar_url", + "userId=$matrix_user_id" + ] + + if let authenticationTypeString = authenticationTypeString { + v2queryStringParts.append("auth=\(authenticationTypeString)") + } + + let v2Params = v2queryStringParts.joined(separator: "&") + + let widgetStringURL = "\(appUrlString)/widgets/jitsi.html?\(v1Params)#\(v2Params)" + + // Build widget data + // We mix v1 and v2 widget data for backward compability + let jitsiWidgetData = JitsiWidgetData() + jitsiWidgetData.domain = serverDomain + jitsiWidgetData.conferenceId = conferenceID + jitsiWidgetData.isAudioOnly = isAudioOnly + jitsiWidgetData.authenticationType = authenticationType?.identifier + + let v2WidgetData: [AnyHashable: Any] = jitsiWidgetData.jsonDictionary() + + var v1AndV2WidgetData = v2WidgetData + v1AndV2WidgetData["widgetSessionId"] = widgetSessionId + + let widgetContent: [AnyHashable: Any] = [ + "url": widgetStringURL, + "type": kWidgetTypeJitsiV1, + "data": v1AndV2WidgetData + ] + + return widgetContent + } } #endif diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index 4271e28f1..26bf83cda 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -24,3 +24,4 @@ #import "MXSession+Riot.h" #import "RoomFilesViewController.h" #import "RoomSettingsViewController.h" +#import "JitsiWidgetData.h"