/* Copyright 2019-2024 New Vector Ltd. Copyright 2017 Vector Creations Ltd SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ #import "WidgetManager.h" #import "GeneratedInterface-Swift.h" #import "JitsiWidgetData.h" #import "MXSession+Riot.h" #pragma mark - Contants NSString *const kWidgetManagerDidUpdateWidgetNotification = @"kWidgetManagerDidUpdateWidgetNotification"; NSString *const WidgetManagerErrorDomain = @"WidgetManagerErrorDomain"; @interface WidgetManager () { // MXSession kind of hash -> Listener for matrix events for widgets. // There is one per matrix session NSMutableDictionary *widgetEventListener; // Success blocks of widgets being created // MXSession kind of hash -> (Widget id -> `createWidget:` success block). NSMutableDictionary*> *successBlockForWidgetCreation; // Failure blocks of widgets being created // MXSession kind of hash -> (Widget id -> `createWidget:` failure block). NSMutableDictionary*> *failureBlockForWidgetCreation; // User id -> scalar token NSMutableDictionary *configs; // User id -> MXSession NSMutableDictionary *matrixSessions; } @end @implementation WidgetManager + (instancetype)sharedManager { static WidgetManager *sharedManager = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedManager = [[WidgetManager alloc] init]; }); return sharedManager; } - (instancetype)init { self = [super init]; if (self) { matrixSessions = [NSMutableDictionary dictionary]; widgetEventListener = [NSMutableDictionary dictionary]; successBlockForWidgetCreation = [NSMutableDictionary dictionary]; failureBlockForWidgetCreation = [NSMutableDictionary dictionary]; [self loadConfigs]; } return self; } - (NSArray *)widgetsInRoom:(MXRoom*)room withRoomState:(MXRoomState*)roomState { return [self widgetsOfTypes:nil inRoom:room withRoomState:roomState]; } - (NSArray *)widgetsOfTypes:(NSArray*)widgetTypes inRoom:(MXRoom*)room withRoomState:(MXRoomState*)roomState { return [self widgetsOfTypes:widgetTypes butNotTypesOf:nil inRoom:room withRoomState:roomState]; } - (NSArray *)widgetsNotOfTypes:(NSArray*)notWidgetTypes inRoom:(MXRoom*)room withRoomState:(MXRoomState*)roomState; { return [self widgetsOfTypes:nil butNotTypesOf:notWidgetTypes inRoom:room withRoomState:roomState]; } - (NSArray *)widgetsOfTypes:(NSArray*)widgetTypes butNotTypesOf:(NSArray*)notWidgetTypes inRoom:(MXRoom*)room withRoomState:(MXRoomState*)roomState; { // Widget id -> widget NSMutableDictionary *widgets = [NSMutableDictionary dictionary]; // Get all widgets state events in the room NSMutableArray *widgetEvents = [NSMutableArray arrayWithArray:[roomState stateEventsWithType:kWidgetMatrixEventTypeString]]; [widgetEvents addObjectsFromArray:[roomState stateEventsWithType:kWidgetModularEventTypeString]]; // There can be several widgets state events for a same widget but // only the last one must be considered. // Order widgetEvents with the last event first [widgetEvents sortUsingComparator:^NSComparisonResult(MXEvent *event1, MXEvent *event2) { NSComparisonResult result = NSOrderedAscending; if (event2.originServerTs > event1.originServerTs) { result = NSOrderedDescending; } else if (event2.originServerTs == event1.originServerTs) { result = NSOrderedSame; } return result; }]; // Create each widget from its lastest widgets state event for (MXEvent *widgetEvent in widgetEvents) { // Filter widget types if required if (widgetTypes || notWidgetTypes) { NSString *widgetType; MXJSONModelSetString(widgetType, widgetEvent.content[@"type"]); if (widgetType) { if (widgetTypes && NSNotFound == [widgetTypes indexOfObject:widgetType]) { continue; } if (notWidgetTypes && NSNotFound != [notWidgetTypes indexOfObject:widgetType]) { continue; } } } // widgetEvent.stateKey = widget id if (!widgets[widgetEvent.stateKey]) { Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent inMatrixSession:room.mxSession]; if (widget) { widgets[widget.widgetId] = widget; } } } // Return active widgets only NSMutableArray *activeWidgets = [NSMutableArray array]; for (Widget *widget in widgets.allValues) { if (widget.isActive) { [activeWidgets addObject:widget]; } } return activeWidgets; } - (NSArray *)userWidgets:(MXSession*)mxSession { return [self userWidgets:mxSession ofTypes:nil]; } - (NSArray *)userWidgets:(MXSession*)mxSession ofTypes:(NSArray*)widgetTypes { // Get all widgets in the user account data NSMutableArray *userWidgets = [NSMutableArray array]; for (NSDictionary *widgetEventContent in [mxSession.accountData accountDataForEventType:kMXAccountDataTypeUserWidgets].allValues) { if (![widgetEventContent isKindOfClass:NSDictionary.class]) { MXLogDebug(@"[WidgetManager] userWidgets: ERROR: invalid user widget format: %@", widgetEventContent); continue; } // Patch: Modular used a malformed key: "stateKey" instead of "state_key" // TODO: To remove once fixed server side NSDictionary *widgetEventContentFixed = widgetEventContent; if (!widgetEventContent[@"state_key"] && widgetEventContent[@"stateKey"]) { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:widgetEventContent]; dict[@"state_key"] = widgetEventContent[@"stateKey"]; widgetEventContentFixed = dict; } MXEvent *widgetEvent = [MXEvent modelFromJSON:widgetEventContentFixed]; if (widgetEvent && (!widgetTypes || [widgetTypes containsObject:widgetEvent.content[@"type"]])) { Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent inMatrixSession:mxSession]; if (widget) { [userWidgets addObject:widget]; } } } return userWidgets; } - (MXHTTPOperation *)createWidget:(NSString*)widgetId withContent:(NSDictionary*)widgetContent inRoom:(MXRoom*)room success:(void (^)(Widget *widget))success failure:(void (^)(NSError *error))failure { // Create an empty operation that will be mutated later MXHTTPOperation *operation = [[MXHTTPOperation alloc] init]; MXWeakify(self); [self checkWidgetPermissionInRoom:room success:^{ MXStrongifyAndReturnIfNil(self); NSString *hash = [NSString stringWithFormat:@"%p", room.mxSession]; self->successBlockForWidgetCreation[hash][widgetId] = success; self->failureBlockForWidgetCreation[hash][widgetId] = failure; // Send a state event with the widget data // TODO: This API will be shortly replaced by a pure modular API // TODO: Move to kWidgetMatrixEventTypeString ("m.widget") type but when? MXHTTPOperation *operation2 = [room sendStateEventOfType:kWidgetModularEventTypeString content:widgetContent stateKey:widgetId success:nil failure:failure]; [operation mutateTo:operation2]; } failure:^(NSError *error) { if (failure) { failure(error); } }]; return operation; } - (MXHTTPOperation *)createJitsiWidgetInRoom:(MXRoom*)room withVideo:(BOOL)video success:(void (^)(Widget *jitsiWidget))success failure:(void (^)(NSError *error))failure { MXHTTPOperation *operation = [MXHTTPOperation new]; NSString *userId = room.mxSession.myUser.userId; WidgetManagerConfig *config = [self configForUser:userId]; if (!config.hasUrls) { MXLogDebug(@"[WidgetManager] createJitsiWidgetInRoom: Error: no integration manager API URL for user %@", userId); failure(self.errorForNonConfiguredIntegrationManager); return nil; } RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:room.mxSession]; if (!sharedSettings.hasIntegrationProvisioningEnabled) { MXLogDebug(@"[WidgetManager] createJitsiWidgetInRoom: Error: Disabled integration manager for user %@", userId); failure(self.errorForDisabledIntegrationManager); return nil; } // Build data for a jitsi widget // Riot-Web still uses V1 type NSString *widgetId = [NSString stringWithFormat:@"%@_%@_%@", kWidgetTypeJitsiV1, room.mxSession.myUser.userId, @((uint64_t)([[NSDate date] timeIntervalSince1970] * 1000))]; NSURL *preferredJitsiServerUrl = [room.mxSession vc_homeserverConfiguration].jitsi.serverURL; if (!preferredJitsiServerUrl) { MXLogDebug(@"[WidgetManager] createJitsiWidgetInRoom: Error: No Jitsi server URL provided"); failure(self.errorForUnavailableJitsiURL); return nil; } #ifdef CALL_STACK_JINGLE JitsiService *jitsiService = JitsiService.shared; operation = [jitsiService createJitsiWidgetContentWithJitsiServerURL:preferredJitsiServerUrl roomID:room.roomId isAudioOnly:!video success:^(NSDictionary * _Nonnull widgetContent) { MXHTTPOperation *operation2 = [self createWidget:widgetId withContent:widgetContent inRoom:room success:success failure:failure]; [operation mutateTo:operation2]; } failure:^(NSError * _Nonnull error) { if (failure) { failure(error); } }]; return operation; #else return nil; #endif } - (MXHTTPOperation *)closeWidget:(NSString *)widgetId inRoom:(MXRoom *)room success:(void (^)(void))success failure:(void (^)(NSError *))failure { // Create an empty operation that will be mutated later MXHTTPOperation *operation = [[MXHTTPOperation alloc] init]; [self checkWidgetPermissionInRoom:room success:^{ // Send a state event with an empty content to disable the widget // TODO: This API will be shortly replaced by a pure modular API // TODO: Move to kWidgetMatrixEventTypeString ("m.widget") type but when? MXHTTPOperation *operation2 = [room sendStateEventOfType:kWidgetModularEventTypeString content:@{} stateKey:widgetId success:^(NSString *eventId) { if (success) { success(); } } failure:failure]; [operation mutateTo:operation2]; } failure:^(NSError *error) { if (failure) { failure(error); } }]; return operation; } /** Check user's power for widgets management in a room. @param room the room to check. */ - (void)checkWidgetPermissionInRoom:(MXRoom *)room success:(dispatch_block_t)success failure:(void (^)(NSError *))failure { [room state:^(MXRoomState *roomState) { NSError *error; // Check user's power in the room MXRoomPowerLevels *powerLevels = roomState.powerLevels; NSInteger oneSelfPowerLevel = [powerLevels powerLevelOfUserWithUserID:room.mxSession.myUser.userId]; // The user must be able to send state events to manage widgets if (oneSelfPowerLevel < powerLevels.stateDefault) { error = [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeNotEnoughPower userInfo:@{ NSLocalizedDescriptionKey: [VectorL10n widgetNoPowerToManage] }]; } if (error) { failure(error); } else { success(); } }]; } - (void)addMatrixSession:(MXSession *)mxSession { __weak __typeof__(self) weakSelf = self; matrixSessions[mxSession.matrixRestClient.credentials.userId] = mxSession; NSString *hash = [NSString stringWithFormat:@"%p", mxSession]; id listener = [mxSession listenToEventsOfTypes:@[kWidgetMatrixEventTypeString, kWidgetModularEventTypeString] onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { typeof(self) self = weakSelf; if (self && direction == MXTimelineDirectionForwards) { // stateKey = widgetId NSString *widgetId = event.stateKey; if (!widgetId) { MXLogDebug(@"[WidgetManager] Error: New widget detected with no id in %@: %@", event.roomId, event.JSONDictionary); return; } MXLogDebug(@"[WidgetManager] New widget detected: %@ in %@", widgetId, event.roomId); Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:mxSession]; if (widget) { // If it is a widget we have just created, indicate its creation is complete if (self->successBlockForWidgetCreation[hash][widgetId]) { self->successBlockForWidgetCreation[hash][widgetId](widget); } // Broadcast the generic notification [[NSNotificationCenter defaultCenter] postNotificationName:kWidgetManagerDidUpdateWidgetNotification object:widget]; #ifdef CALL_STACK_JINGLE // End jitsi call if a active call exists and widget has been updated to not be active if ([[AppDelegate theDelegate].callPresenter.jitsiVC.widget.widgetId isEqualToString: widget.widgetId] && [[AppDelegate theDelegate].callPresenter.jitsiVC.widget.roomId isEqualToString: event.roomId] && !widget.isActive) { [[AppDelegate theDelegate].callPresenter endActiveJitsiCall]; } #endif } else { MXLogDebug(@"[WidgetManager] Cannot decode new widget - event: %@", event); if (self->failureBlockForWidgetCreation[hash][widgetId]) { // If it is a widget we have just created, indicate its creation has failed somehow NSError *error = [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeCreationFailed userInfo:@{ NSLocalizedDescriptionKey: [VectorL10n widgetCreationFailure] }]; self->failureBlockForWidgetCreation[hash][widgetId](error); } } [self->successBlockForWidgetCreation[hash] removeObjectForKey:widgetId]; [self->failureBlockForWidgetCreation[hash] removeObjectForKey:widgetId]; } }]; widgetEventListener[hash] = listener; successBlockForWidgetCreation[hash] = [NSMutableDictionary dictionary]; failureBlockForWidgetCreation[hash] = [NSMutableDictionary dictionary]; } - (void)removeMatrixSession:(MXSession *)mxSession { // Remove by value in a dict for (NSString *key in [matrixSessions allKeysForObject:mxSession]) { [matrixSessions removeObjectForKey:key]; } // mxSession.myUser.userId and mxSession.matrixRestClient.credentials.userId may be nil here // So, use a kind of hash value instead NSString *hash = [NSString stringWithFormat:@"%p", mxSession]; id listener = widgetEventListener[hash]; [mxSession removeListener:listener]; [widgetEventListener removeObjectForKey:hash]; [successBlockForWidgetCreation removeObjectForKey:hash]; [failureBlockForWidgetCreation removeObjectForKey:hash]; } - (MXSession*)matrixSessionForUser:(NSString*)userId { return matrixSessions[userId]; } - (void)deleteDataForUser:(NSString *)userId { [configs removeObjectForKey:userId]; [self saveConfigs]; } #pragma mark - User integrations configuration - (WidgetManagerConfig*)createWidgetManagerConfigForUser:(NSString*)userId { WidgetManagerConfig *config; MXSession *session = [self matrixSessionForUser:userId]; // Find the integrations settings for the user // First, look at matrix account // TODO in another user story // Then, try to the homeserver configuration MXWellknownIntegrationsManager *integrationsManager = session.homeserverWellknown.integrations.managers.firstObject; if (integrationsManager) { config = [[WidgetManagerConfig alloc] initWithApiUrl:integrationsManager.apiUrl uiUrl:integrationsManager.uiUrl]; } else { // Fallback on app settings config = [self createWidgetManagerConfigWithAppSettings]; } return config; } - (WidgetManagerConfig*)createWidgetManagerConfigWithAppSettings { return [[WidgetManagerConfig alloc] initWithApiUrl:BuildSettings.integrationsRestApiUrlString uiUrl:BuildSettings.integrationsUiUrlString]; } #pragma mark - Modular interface - (WidgetManagerConfig*)configForUser:(NSString*)userId { // Return a default config by default return configs[userId] ? configs[userId] : [self createWidgetManagerConfigForUser:userId]; } - (BOOL)hasIntegrationManagerForUser:(NSString*)userId { return [self configForUser:userId].hasUrls; } - (void)setConfig:(WidgetManagerConfig*)config forUser:(NSString*)userId { configs[userId] = config; [self saveConfigs]; } - (MXHTTPOperation *)getScalarTokenForMXSession:(MXSession*)mxSession validate:(BOOL)validate success:(void (^)(NSString *scalarToken))success failure:(void (^)(NSError *error))failure; { MXHTTPOperation *operation; __block NSString *scalarToken = [self scalarTokenForMXSession:mxSession]; if (scalarToken) { if (!validate) { success(scalarToken); } else { operation = [self validateScalarToken:scalarToken forMXSession:mxSession complete:^(BOOL valid) { if (valid) { success(scalarToken); } else { MXLogDebug(@"[WidgetManager] getScalarTokenForMXSession: Invalid stored token. Need to register for a new token"); MXHTTPOperation *operation2 = [self registerForScalarToken:mxSession success:success failure:failure]; [operation mutateTo:operation2]; } } failure:failure]; } } else { MXLogDebug(@"[WidgetManager] getScalarTokenForMXSession: Need to register for a token"); operation = [self registerForScalarToken:mxSession success:success failure:failure]; } return operation; } - (MXHTTPOperation *)registerForScalarToken:(MXSession*)mxSession success:(void (^)(NSString *scalarToken))success failure:(void (^)(NSError *error))failure { MXHTTPOperation *operation; NSString *userId = mxSession.myUser.userId; MXLogDebug(@"[WidgetManager] registerForScalarToken"); WidgetManagerConfig *config = [self configForUser:userId]; if (!config.hasUrls) { MXLogDebug(@"[WidgetManager] registerForScalarToken: Error: no integration manager API URL for user %@", mxSession.myUser.userId); failure(self.errorForNonConfiguredIntegrationManager); return nil; } MXWeakify(self); operation = [mxSession.matrixRestClient openIdToken:^(MXOpenIdToken *tokenObject) { MXStrongifyAndReturnIfNil(self); // Exchange the token for a scalar token __block MXHTTPClient *httpClient = [[MXHTTPClient alloc] initWithBaseURL:config.apiUrl andOnUnrecognizedCertificateBlock:nil]; MXHTTPOperation *operation2 = [httpClient requestWithMethod:@"POST" path:@"register?v=1.1" parameters:tokenObject.JSONDictionary success:^(NSDictionary *JSONResponse) { httpClient = nil; NSString *scalarToken; MXJSONModelSetString(scalarToken, JSONResponse[@"scalar_token"]) config.scalarToken = scalarToken; self->configs[userId] = config; [self saveConfigs]; // Validate it (this mostly checks to see if the IM needs us to agree to some terms) MXHTTPOperation *operation3 = [self validateScalarToken:scalarToken forMXSession:mxSession complete:^(BOOL valid) { if (success) { success(scalarToken); } } failure:failure]; [operation mutateTo:operation3]; } failure:^(NSError *error) { httpClient = nil; MXLogDebug(@"[WidgetManager] registerForScalarToken: Failed to register. Error: %@", error); if (failure) { // Specialise the error NSError *error = [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeFailedToConnectToIntegrationsServer userInfo:@{ NSLocalizedDescriptionKey: [VectorL10n widgetIntegrationsServerFailedToConnect] }]; failure(error); } }]; [operation mutateTo:operation2]; } failure:^(NSError *error) { MXLogDebug(@"[WidgetManager] registerForScalarToken. Error in openIdToken request"); if (failure) { failure(error); } }]; return operation; } - (MXHTTPOperation *)validateScalarToken:(NSString*)scalarToken forMXSession:(MXSession*)mxSession complete:(void (^)(BOOL valid))complete failure:(void (^)(NSError *error))failure { NSString *userId = mxSession.myUser.userId; WidgetManagerConfig *config = [self configForUser:userId]; if (!config.hasUrls) { MXLogDebug(@"[WidgetManager] validateScalarToken: Error: no integration manager API URL for user %@", mxSession.myUser.userId); failure(self.errorForNonConfiguredIntegrationManager); return nil; } __block MXHTTPClient *httpClient = [[MXHTTPClient alloc] initWithBaseURL:config.apiUrl andOnUnrecognizedCertificateBlock:nil]; return [httpClient requestWithMethod:@"GET" path:[NSString stringWithFormat:@"account?v=1.1&scalar_token=%@", scalarToken] parameters:nil success:^(NSDictionary *JSONResponse) { httpClient = nil; NSString *userId; MXJSONModelSetString(userId, JSONResponse[@"user_id"]) if ([userId isEqualToString:mxSession.myUser.userId]) { complete(YES); } else { MXLogDebug(@"[WidgetManager] validateScalarToken. Unexpected modular/account response: %@", JSONResponse); complete(NO); } } failure:^(NSError *error) { httpClient = nil; NSHTTPURLResponse *urlResponse = [MXHTTPOperation urlResponseFromError:error]; MXLogDebug(@"[WidgetManager] validateScalarToken. Error in modular/account request. statusCode: %@", @(urlResponse.statusCode)); MXError *mxError = [[MXError alloc] initWithNSError:error]; if ([mxError.errcode isEqualToString:kMXErrCodeStringTermsNotSigned]) { MXLogDebug(@"[WidgetManager] validateScalarToke. Error: Need to accept terms"); NSError *termsNotSignedError = [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeTermsNotSigned userInfo:@{ NSLocalizedDescriptionKey:error.userInfo[NSLocalizedDescriptionKey] }]; failure(termsNotSignedError); } else if (urlResponse && urlResponse.statusCode / 100 != 2) { complete(NO); } else if (failure) { failure(error); } }]; } - (BOOL)isScalarUrl:(NSString *)urlString forUser:(NSString*)userId { BOOL isScalarUrl = NO; // TODO: Do we need to add `integrationsWidgetsUrls` to `WidgetManagerConfig`? NSArray *scalarUrlStrings = BWIBuildSettings.shared.integrationsScalarWidgetsPaths; if (scalarUrlStrings.count == 0) { NSString *apiUrl = [self configForUser:userId].apiUrl; if (apiUrl) { scalarUrlStrings = @[apiUrl]; } } for (NSString *scalarUrlString in scalarUrlStrings) { if ([urlString hasPrefix:scalarUrlString]) { isScalarUrl = YES; break; } } return isScalarUrl; } #pragma mark - Private methods - (NSString *)scalarTokenForMXSession:(MXSession *)mxSession { return configs[mxSession.myUser.userId].scalarToken; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated" - (void)loadConfigs { NSUserDefaults *userDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; NSDictionary *scalarTokens = [userDefaults objectForKey:@"scalarTokens"]; if (scalarTokens) { // Manage migration to WidgetManagerConfig configs = [NSMutableDictionary dictionary]; for (NSString *userId in scalarTokens) { NSString *scalarToken = scalarTokens[userId]; MXLogDebug(@"[WidgetManager] migrate scalarTokens to integrationManagerConfigs for %@", userId); WidgetManagerConfig *config = [self createWidgetManagerConfigWithAppSettings]; config.scalarToken = scalarToken; configs[userId] = config; } [self saveConfigs]; [userDefaults removeObjectForKey:@"scalarTokens"]; } else { NSData *configsData = [userDefaults objectForKey:@"integrationManagerConfigs"]; if (configsData) { // We need to map the config class name since the bundle name was updated otherwise unarchiving crashes. [NSKeyedUnarchiver setClass:WidgetManagerConfig.class forClassName:@"Riot.WidgetManagerConfig"]; configs = [NSMutableDictionary dictionaryWithDictionary:[NSKeyedUnarchiver unarchiveObjectWithData:configsData]]; } if (!configs) { configs = [NSMutableDictionary dictionary]; } } } - (void)saveConfigs { NSUserDefaults *userDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; [userDefaults setObject:[NSKeyedArchiver archivedDataWithRootObject:configs] forKey:@"integrationManagerConfigs"]; } #pragma clang diagnostic pop #pragma mark - Errors - (NSError*)errorForNonConfiguredIntegrationManager { return [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeNoIntegrationsServerConfigured userInfo:@{NSLocalizedDescriptionKey: [VectorL10n widgetNoIntegrationsServerConfigured]}]; } - (NSError*)errorForDisabledIntegrationManager { return [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeDisabledIntegrationsServer userInfo:@{NSLocalizedDescriptionKey: [VectorL10n widgetIntegrationManagerDisabled]}]; } - (NSError*)errorForUnavailableJitsiURL { return [NSError errorWithDomain:WidgetManagerErrorDomain code:WidgetManagerErrorCodeUnavailableJitsiURL userInfo:@{NSLocalizedDescriptionKey: VectorL10n.callJitsiUnableToStart}]; } @end