/* Copyright 2018-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 "WidgetViewController.h" #import "IntegrationManagerViewController.h" #import "GeneratedInterface-Swift.h" NSString *const kJavascriptSendResponseToPostMessageAPI = @"riotIOS.sendResponse('%@', %@);"; @interface WidgetViewController () @property (nonatomic, strong) ServiceTermsModalCoordinatorBridgePresenter *serviceTermsModalCoordinatorBridgePresenter; @property (nonatomic, strong) NSString *widgetUrl; @property (nonatomic, strong) SlidingModalPresenter *slidingModalPresenter; @end @implementation WidgetViewController @synthesize widget; - (instancetype)initWithUrl:(NSString*)widgetUrl forWidget:(Widget*)theWidget { // The opening of the url is delayed in viewWillAppear where we will check // the widget permission self = [super initWithURL:nil]; if (self) { self.widgetUrl = widgetUrl; widget = theWidget; } return self; } - (void)viewDidLoad { [super viewDidLoad]; webView.scrollView.bounces = NO; // Disable opacity so that the webview background uses the current interface theme webView.opaque = NO; if (widget) { self.navigationItem.title = widget.name ? widget.name : widget.type; UIBarButtonItem *menuButton = [[UIBarButtonItem alloc] initWithImage:AssetImages.roomContextMenuMore.image style:UIBarButtonItemStylePlain target:self action:@selector(onMenuButtonPressed:)]; self.navigationItem.rightBarButtonItem = menuButton; } self.slidingModalPresenter = [SlidingModalPresenter new]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Check widget permission before opening the widget [self checkWidgetPermissionWithCompletion:^(BOOL granted) { [self.slidingModalPresenter dismissWithAnimated:YES completion:nil]; if (granted) { self.URL = self.widgetUrl; } else { [self withdrawViewControllerAnimated:YES completion:nil]; } }]; } - (void)reloadWidget { self.URL = self.widgetUrl; } - (BOOL)hasUserEnoughPowerToManageCurrentWidget { BOOL hasUserEnoughPower = NO; MXSession *session = widget.mxSession; MXRoom *room = [session roomWithRoomId:self.widget.roomId]; MXRoomState *roomState = room.dangerousSyncState; if (roomState) { // Check user's power in the room MXRoomPowerLevels *powerLevels = roomState.powerLevels; NSInteger oneSelfPowerLevel = [powerLevels powerLevelOfUserWithUserID:session.myUser.userId]; // The user must be able to send state events to manage widgets if (oneSelfPowerLevel >= powerLevels.stateDefault) { hasUserEnoughPower = YES; } } return hasUserEnoughPower; } - (void)removeCurrentWidget { WidgetManager *widgetManager = [WidgetManager sharedManager]; MXRoom *room = [self.widget.mxSession roomWithRoomId:self.widget.roomId]; NSString *widgetId = self.widget.widgetId; if (room && widgetId) { [widgetManager closeWidget:widgetId inRoom:room success:^{ } failure:^(NSError *error) { MXLogDebug(@"[WidgetVC] removeCurrentWidget failed. Error: %@", error); }]; } } - (void)showErrorAsAlert:(NSError*)error { NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; if (!title) { if (msg) { title = msg; msg = nil; } else { title = [VectorL10n error]; } } __weak __typeof__(self) weakSelf = self; UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { typeof(self) self = weakSelf; if (self) { // Leave this widget VC [self withdrawViewControllerAnimated:YES completion:nil]; } }]]; [self presentViewController:alert animated:YES completion:nil]; } #pragma mark - Widget Permission - (void)checkWidgetPermissionWithCompletion:(void (^)(BOOL granted))completion { MXSession *session = widget.mxSession; if ([widget.widgetEvent.sender isEqualToString:session.myUser.userId]) { // No need of more permission check if the user created the widget completion(YES); return; } // Check permission in user Riot settings __block RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:session]; WidgetPermission permission = [sharedSettings permissionFor:widget]; if (permission == WidgetPermissionGranted) { completion(YES); } else { // Note: ask permission again if the user previously declined it [self askPermissionWithCompletion:^(BOOL granted) { // Update the settings in user account data in parallel [sharedSettings setPermission:granted ? WidgetPermissionGranted : WidgetPermissionDeclined for:self.widget success:^ { sharedSettings = nil; } failure:^(NSError * _Nullable error) { MXLogDebug(@"[WidgetVC] setPermissionForWidget failed. Error: %@", error); sharedSettings = nil; }]; completion(granted); }]; } } - (void)askPermissionWithCompletion:(void (^)(BOOL granted))completion { NSString *widgetCreatorUserId = self.widget.widgetEvent.sender ?: [VectorL10n roomParticipantsUnknown]; MXSession *session = widget.mxSession; MXRoom *room = [session roomWithRoomId:self.widget.widgetEvent.roomId]; MXRoomState *roomState = room.dangerousSyncState; MXRoomMember *widgetCreatorRoomMember = [roomState.members memberWithUserId:widgetCreatorUserId]; NSString *widgetDomain = @""; if (widget.url) { NSString *host = [[NSURL alloc] initWithString:widget.url].host; if (host) { widgetDomain = host; } } MXMediaManager *mediaManager = widget.mxSession.mediaManager; NSString *widgetCreatorDisplayName = widgetCreatorRoomMember.displayname; NSString *widgetCreatorAvatarURL = widgetCreatorRoomMember.avatarUrl; NSArray *permissionStrings = @[ [VectorL10n roomWidgetPermissionDisplayNamePermission], [BWIL10n roomWidgetPermissionAvatarUrlPermission], [VectorL10n roomWidgetPermissionUserIdPermission], [VectorL10n roomWidgetPermissionThemePermission], [VectorL10n roomWidgetPermissionWidgetIdPermission], [VectorL10n roomWidgetPermissionRoomIdPermission] ]; WidgetPermissionViewModel *widgetPermissionViewModel = [[WidgetPermissionViewModel alloc] initWithCreatorUserId:widgetCreatorUserId creatorDisplayName:widgetCreatorDisplayName creatorAvatarUrl:widgetCreatorAvatarURL widgetDomain:widgetDomain isWebviewWidget:YES widgetPermissions:permissionStrings mediaManager:mediaManager]; WidgetPermissionViewController *widgetPermissionViewController = [WidgetPermissionViewController instantiateWith:widgetPermissionViewModel]; widgetPermissionViewController.didTapContinueButton = ^{ completion(YES); }; widgetPermissionViewController.didTapCloseButton = ^{ completion(NO); }; [self.slidingModalPresenter present:widgetPermissionViewController from:self animated:YES completion:nil]; } - (void)revokePermissionForCurrentWidget { MXSession *session = widget.mxSession; __block RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:session]; [sharedSettings setPermission:WidgetPermissionDeclined for:widget success:^{ sharedSettings = nil; } failure:^(NSError * _Nullable error) { MXLogDebug(@"[WidgetVC] revokePermissionForCurrentWidget failed. Error: %@", error); sharedSettings = nil; }]; } #pragma mark - Contextual Menu - (IBAction)onMenuButtonPressed:(id)sender { [self showMenu]; } -(void)showMenu { MXSession *session = widget.mxSession; UIAlertController *menu = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [menu addAction:[UIAlertAction actionWithTitle:[VectorL10n widgetMenuRefresh] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self reloadWidget]; }]]; NSURL *url = [NSURL URLWithString:self.widgetUrl]; if (url && [[UIApplication sharedApplication] canOpenURL:url]) { [menu addAction:[UIAlertAction actionWithTitle:[VectorL10n widgetMenuOpenOutside] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) { }]; }]]; } if (![widget.widgetEvent.sender isEqualToString:session.myUser.userId]) { [menu addAction:[UIAlertAction actionWithTitle:[VectorL10n widgetMenuRevokePermission] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self revokePermissionForCurrentWidget]; [self withdrawViewControllerAnimated:YES completion:nil]; }]]; } if ([self hasUserEnoughPowerToManageCurrentWidget]) { [menu addAction:[UIAlertAction actionWithTitle:[VectorL10n widgetMenuRemove] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self removeCurrentWidget]; [self withdrawViewControllerAnimated:YES completion:nil]; }]]; } [menu addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { }]]; [self presentViewController:menu animated:YES completion:nil]; } #pragma mark - WKNavigationDelegate - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { [self enableDebug]; // Setup js code NSString *path = [[NSBundle mainBundle] pathForResource:@"postMessageAPI" ofType:@"js"]; NSString *js = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; [webView evaluateJavaScript:js completionHandler:nil]; [self stopActivityIndicator]; // Check connectivity if ([AppDelegate theDelegate].isOffline) { // The web page may be in the cache, so its loading will be successful // but we cannot go further, it often leads to a blank screen. // So, display an error so that the user can escape. NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:@{ NSLocalizedDescriptionKey : [VectorL10n networkOfflinePrompt] }]; [self showErrorAsAlert:error]; } } - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSString *urlString = navigationAction.request.URL.absoluteString; // TODO: We should use the WebKit PostMessage API and the // `didReceiveScriptMessage` delegate to manage the JS<->Native bridge if ([urlString hasPrefix:@"js:"]) { // Listen only to the scheme of the JS<->Native bridge NSString *jsonString = [[[urlString componentsSeparatedByString:@"js:"] lastObject] stringByRemovingPercentEncoding]; NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; NSError *error; NSDictionary *parameters = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&error]; if (!error) { // Retrieve the js event payload data NSDictionary *eventData; MXJSONModelSetDictionary(eventData, parameters[@"event.data"]); NSString *requestId; MXJSONModelSetString(requestId, eventData[@"_id"]); if (requestId) { [self onPostMessageRequest:requestId data:eventData]; } else { MXLogDebug(@"[WidgetVC] shouldStartLoadWithRequest: ERROR: Missing request id in postMessage API %@", parameters); } } decisionHandler(WKNavigationActionPolicyCancel); return; } if (navigationAction.navigationType == WKNavigationTypeLinkActivated) { NSURL *linkURL = navigationAction.request.URL; // Open links outside the app [[UIApplication sharedApplication] vc_open:linkURL completionHandler:^(BOOL success) { if (!success) { MXLogDebug(@"[WidgetVC] webView:decidePolicyForNavigationAction:decisionHandler fail to open external link: %@", linkURL); } }]; decisionHandler(WKNavigationActionPolicyCancel); return; } decisionHandler(WKNavigationActionPolicyAllow); } - (void)webView:(WKWebView *)webView didFailNavigation:(WKNavigation *)navigation withError:(NSError *)error { // Filter out the users's scalar token NSString *errorDescription = error.description; errorDescription = [self stringByReplacingScalarTokenInString:errorDescription byScalarToken:@"..."]; MXLogDebug(@"[WidgetVC] didFailLoadWithError: %@", errorDescription); [self stopActivityIndicator]; [self showErrorAsAlert:error]; } - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) { NSHTTPURLResponse * response = (NSHTTPURLResponse *)navigationResponse.response; if (response.statusCode != 200) { MXLogDebug(@"[WidgetVC] decidePolicyForNavigationResponse: statusCode: %@", @(response.statusCode)); } if (response.statusCode == 403 && [[WidgetManager sharedManager] isScalarUrl:self.URL forUser:self.widget.mxSession.myUser.userId]) { [self fixScalarToken]; } } decisionHandler(WKNavigationResponsePolicyAllow); } #pragma mark - postMessage API - (void)onPostMessageRequest:(NSString*)requestId data:(NSDictionary*)requestData { NSString *action; MXJSONModelSetString(action, requestData[@"action"]); if ([@"m.sticker" isEqualToString:action]) { // Extract the sticker event content and send it as is // The key should be "data" according to https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing // TODO: Fix it once spec is finalised NSDictionary *widgetData; NSDictionary *stickerContent; MXJSONModelSetDictionary(widgetData, requestData[@"widgetData"]); if (widgetData) { MXJSONModelSetDictionary(stickerContent, widgetData[@"content"]); } if (stickerContent) { // Let the data source manage the sending cycle [_roomDataSource sendEventOfType:kMXEventTypeStringSticker content:stickerContent success:nil failure:nil]; } else { MXLogDebug(@"[WidgetVC] onPostMessageRequest: ERROR: Invalid content for m.sticker: %@", requestData); } // Consider we are done with the sticker picker widget [self withdrawViewControllerAnimated:YES completion:nil]; } else if ([@"integration_manager_open" isEqualToString:action]) { NSDictionary *widgetData; NSString *integType, *integId; MXJSONModelSetDictionary(widgetData, requestData[@"widgetData"]); if (widgetData) { MXJSONModelSetString(integType, widgetData[@"integType"]); MXJSONModelSetString(integId, widgetData[@"integId"]); } if (integType && integId) { // Open the integration manager requested page IntegrationManagerViewController *modularVC = [[IntegrationManagerViewController alloc] initForMXSession:self.roomDataSource.mxSession inRoom:self.roomDataSource.roomId screen:[IntegrationManagerViewController screenForWidget:integType] widgetId:integId]; [self presentViewController:modularVC animated:NO completion:nil]; } else { MXLogDebug(@"[WidgetVC] onPostMessageRequest: ERROR: Invalid content for integration_manager_open: %@", requestData); } } else { MXLogDebug(@"[WidgetVC] onPostMessageRequest: ERROR: Unsupported action: %@: %@", action, requestData); } } - (void)sendBoolResponse:(BOOL)response toRequest:(NSString*)requestId { // Convert BOOL to "true" or "false" NSString *js = [NSString stringWithFormat:kJavascriptSendResponseToPostMessageAPI, requestId, response ? @"true" : @"false"]; [webView evaluateJavaScript:js completionHandler:nil]; } - (void)sendIntegerResponse:(NSUInteger)response toRequest:(NSString*)requestId { NSString *js = [NSString stringWithFormat:kJavascriptSendResponseToPostMessageAPI, requestId, @(response)]; [webView evaluateJavaScript:js completionHandler:nil]; } - (void)sendNSObjectResponse:(NSObject*)response toRequest:(NSString*)requestId { NSString *jsString; if (response) { // Convert response into a JS object through a JSON string NSData *jsonData = [NSJSONSerialization dataWithJSONObject:response options:0 error:0]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; jsString = [NSString stringWithFormat:@"JSON.parse('%@')", jsonString]; } else { jsString = @"null"; } NSString *js = [NSString stringWithFormat:kJavascriptSendResponseToPostMessageAPI, requestId, jsString]; [webView evaluateJavaScript:js completionHandler:nil]; } - (void)sendError:(NSString*)message toRequest:(NSString*)requestId { MXLogDebug(@"[WidgetVC] sendError: Action %@ failed with message: %@", requestId, message); // TODO: JS has an additional optional parameter: nestedError [self sendNSObjectResponse:@{ @"error": @{ @"message": message } } toRequest:requestId]; } #pragma mark - Private methods - (NSString *)stringByReplacingScalarTokenInString:(NSString*)string byScalarToken:(NSString*)scalarToken { if (!string) { return nil; } NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"scalar_token=\\w*" options:NSRegularExpressionCaseInsensitive error:nil]; return [regex stringByReplacingMatchesInString:string options:0 range:NSMakeRange(0, string.length) withTemplate:[NSString stringWithFormat:@"scalar_token=%@", scalarToken]]; } /** Reset the scalar token used in the webview URL. */ - (void)fixScalarToken { MXLogDebug(@"[WidgetVC] fixScalarToken"); self->webView.hidden = YES; // Get a fresh new scalar token [WidgetManager.sharedManager deleteDataForUser:widget.mxSession.myUser.userId]; MXWeakify(self); [WidgetManager.sharedManager getScalarTokenForMXSession:widget.mxSession validate:NO success:^(NSString *scalarToken) { MXStrongifyAndReturnIfNil(self); MXLogDebug(@"[WidgetVC] fixScalarToken: DONE"); [self loadDataWithScalarToken:scalarToken]; } failure:^(NSError *error) { MXLogDebug(@"[WidgetVC] fixScalarToken: Error: %@", error); if ([error.domain isEqualToString:WidgetManagerErrorDomain] && error.code == WidgetManagerErrorCodeTermsNotSigned) { [self presentTerms]; } else { [self showErrorAsAlert:error]; } }]; } - (void)loadDataWithScalarToken:(NSString*)scalarToken { self.URL = [self stringByReplacingScalarTokenInString:self.URL byScalarToken:scalarToken]; self->webView.hidden = NO; } #pragma mark - Service terms - (void)presentTerms { if (self.serviceTermsModalCoordinatorBridgePresenter) { return; } WidgetManagerConfig *config = [[WidgetManager sharedManager] configForUser:widget.mxSession.myUser.userId]; MXLogDebug(@"[WidgetVC] presentTerms for %@", config.baseUrl); ServiceTermsModalCoordinatorBridgePresenter *serviceTermsModalCoordinatorBridgePresenter = [[ServiceTermsModalCoordinatorBridgePresenter alloc] initWithSession:widget.mxSession baseUrl:config.baseUrl serviceType:MXServiceTypeIntegrationManager accessToken:config.scalarToken]; serviceTermsModalCoordinatorBridgePresenter.delegate = self; [serviceTermsModalCoordinatorBridgePresenter presentFrom:self animated:YES]; self.serviceTermsModalCoordinatorBridgePresenter = serviceTermsModalCoordinatorBridgePresenter; } - (void)serviceTermsModalCoordinatorBridgePresenterDelegateDidAccept:(ServiceTermsModalCoordinatorBridgePresenter * _Nonnull)coordinatorBridgePresenter { MXWeakify(self); [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ MXStrongifyAndReturnIfNil(self); WidgetManagerConfig *config = [[WidgetManager sharedManager] configForUser:self->widget.mxSession.myUser.userId]; [self loadDataWithScalarToken:config.scalarToken]; }]; self.serviceTermsModalCoordinatorBridgePresenter = nil; } - (void)serviceTermsModalCoordinatorBridgePresenterDelegateDidDecline:(ServiceTermsModalCoordinatorBridgePresenter * _Nonnull)coordinatorBridgePresenter session:(MXSession * _Nonnull)session { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self withdrawViewControllerAnimated:YES completion:nil]; }]; self.serviceTermsModalCoordinatorBridgePresenter = nil; } - (void)serviceTermsModalCoordinatorBridgePresenterDelegateDidClose:(ServiceTermsModalCoordinatorBridgePresenter * _Nonnull)coordinatorBridgePresenter { self.serviceTermsModalCoordinatorBridgePresenter = nil; } @end