diff --git a/ios/app/src/AppDelegate.m b/ios/app/src/AppDelegate.m index 7bb6af8fe..380b498b9 100644 --- a/ios/app/src/AppDelegate.m +++ b/ios/app/src/AppDelegate.m @@ -16,8 +16,22 @@ #import "AppDelegate.h" +#include +#import + #import +// Weakly load the Intents framework since it's not available on iOS 9. +@import Intents; + +// Constant describing iOS 10.0.0 +static const NSOperatingSystemVersion ios10 = { + .majorVersion = 10, + .minorVersion = 0, + .patchVersion = 0 +}; + + @implementation AppDelegate - (BOOL)application:(UIApplication *)application @@ -32,14 +46,53 @@ continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray *restorableObjects))restorationHandler { + JitsiMeetView *view = (JitsiMeetView *) self.window.rootViewController.view; + if (!view) { + return NO; + } + if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { - JitsiMeetView *view - = (JitsiMeetView *) self.window.rootViewController.view; [view loadURL:userActivity.webpageURL]; return YES; } + // Check for CallKit intents only on iOS >= 10 + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:ios10]) { + if ([userActivity.activityType isEqualToString:@"INStartAudioCallIntent"] + || [userActivity.activityType isEqualToString:@"INStartVideoCallIntent"]) { + INInteraction *interaction = [userActivity interaction]; + INIntent *intent = interaction.intent; + NSString *handle; + BOOL isAudio = NO; + + if ([intent isKindOfClass:[INStartAudioCallIntent class]]) { + INStartAudioCallIntent *startCallIntent + = (INStartAudioCallIntent *)intent; + handle = startCallIntent.contacts.firstObject.personHandle.value; + isAudio = YES; + } else { + INStartVideoCallIntent *startCallIntent + = (INStartVideoCallIntent *)intent; + handle = startCallIntent.contacts.firstObject.personHandle.value; + } + + if (!handle) { + return NO; + } + + // Load the URL contained in the handle + [view loadURLObject:@{ + @"url": handle, + @"configOverwrite": @{ + @"startAudioOnly": @(isAudio) + } + }]; + + return YES; + } + } + return NO; } diff --git a/ios/sdk/sdk.xcodeproj/project.pbxproj b/ios/sdk/sdk.xcodeproj/project.pbxproj index 5263da224..742297b16 100644 --- a/ios/sdk/sdk.xcodeproj/project.pbxproj +++ b/ios/sdk/sdk.xcodeproj/project.pbxproj @@ -14,6 +14,9 @@ 0B93EF7E1EC9DDCD0030D24D /* RCTBridgeWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B93EF7C1EC9DDCD0030D24D /* RCTBridgeWrapper.h */; }; 0B93EF7F1EC9DDCD0030D24D /* RCTBridgeWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B93EF7D1EC9DDCD0030D24D /* RCTBridgeWrapper.m */; }; 0BA13D311EE83FF8007BEF7F /* ExternalAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BA13D301EE83FF8007BEF7F /* ExternalAPI.m */; }; + 0BB9AD771F5EC6CE001C08DB /* CallKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BB9AD761F5EC6CE001C08DB /* CallKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 0BB9AD791F5EC6D7001C08DB /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BB9AD781F5EC6D7001C08DB /* Intents.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 0BB9AD7B1F5EC8F4001C08DB /* CallKit.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BB9AD7A1F5EC8F4001C08DB /* CallKit.m */; }; 0BB9AD7D1F60356D001C08DB /* AppInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BB9AD7C1F60356D001C08DB /* AppInfo.m */; }; 0BCA495F1EC4B6C600B793EE /* AudioMode.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BCA495C1EC4B6C600B793EE /* AudioMode.m */; }; 0BCA49601EC4B6C600B793EE /* POSIX.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BCA495D1EC4B6C600B793EE /* POSIX.m */; }; @@ -32,6 +35,9 @@ 0B93EF7C1EC9DDCD0030D24D /* RCTBridgeWrapper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTBridgeWrapper.h; sourceTree = ""; }; 0B93EF7D1EC9DDCD0030D24D /* RCTBridgeWrapper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBridgeWrapper.m; sourceTree = ""; }; 0BA13D301EE83FF8007BEF7F /* ExternalAPI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ExternalAPI.m; sourceTree = ""; }; + 0BB9AD761F5EC6CE001C08DB /* CallKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CallKit.framework; path = System/Library/Frameworks/CallKit.framework; sourceTree = SDKROOT; }; + 0BB9AD781F5EC6D7001C08DB /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + 0BB9AD7A1F5EC8F4001C08DB /* CallKit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CallKit.m; sourceTree = ""; }; 0BB9AD7C1F60356D001C08DB /* AppInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppInfo.m; sourceTree = ""; }; 0BCA495C1EC4B6C600B793EE /* AudioMode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AudioMode.m; sourceTree = ""; }; 0BCA495D1EC4B6C600B793EE /* POSIX.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = POSIX.m; sourceTree = ""; }; @@ -50,6 +56,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0BB9AD791F5EC6D7001C08DB /* Intents.framework in Frameworks */, + 0BB9AD771F5EC6CE001C08DB /* CallKit.framework in Frameworks */, 0B93EF7B1EC608550030D24D /* CoreText.framework in Frameworks */, 0F65EECE1D95DA94561BB47E /* libPods-JitsiMeet.a in Frameworks */, ); @@ -90,6 +98,7 @@ children = ( 0BCA495C1EC4B6C600B793EE /* AudioMode.m */, 0BB9AD7C1F60356D001C08DB /* AppInfo.m */, + 0BB9AD7A1F5EC8F4001C08DB /* CallKit.m */, 0BA13D301EE83FF8007BEF7F /* ExternalAPI.m */, 0BD906E91EC0C00300C8C18E /* Info.plist */, 0BD906E81EC0C00300C8C18E /* JitsiMeet.h */, @@ -107,6 +116,8 @@ 9C3C6FA2341729836589B856 /* Frameworks */ = { isa = PBXGroup; children = ( + 0BB9AD781F5EC6D7001C08DB /* Intents.framework */, + 0BB9AD761F5EC6CE001C08DB /* CallKit.framework */, 0B93EF7A1EC608550030D24D /* CoreText.framework */, 0BCA49631EC4B76D00B793EE /* WebRTC.framework */, 03F2ADC957FF109849B7FCA1 /* libPods-JitsiMeet.a */, @@ -256,6 +267,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0BB9AD7B1F5EC8F4001C08DB /* CallKit.m in Sources */, 0BB9AD7D1F60356D001C08DB /* AppInfo.m in Sources */, 0B93EF7F1EC9DDCD0030D24D /* RCTBridgeWrapper.m in Sources */, 0BA13D311EE83FF8007BEF7F /* ExternalAPI.m in Sources */, diff --git a/ios/sdk/src/CallKit.m b/ios/sdk/src/CallKit.m new file mode 100644 index 000000000..c302a7775 --- /dev/null +++ b/ios/sdk/src/CallKit.m @@ -0,0 +1,334 @@ +// +// Based on RNCallKit +// +// Original license: +// +// Copyright (c) 2016, Ian Yu-Hsun Lin +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +#import +#import +#import + +#import +#import +#import +#import +#import + +// Weakly load CallKit, because it's not available on iOS 9. +@import CallKit; + + +// Events we will emit. +static NSString *const RNCallKitPerformAnswerCallAction = @"performAnswerCallAction"; +static NSString *const RNCallKitPerformEndCallAction = @"performEndCallAction"; +static NSString *const RNCallKitPerformSetMutedCallAction = @"performSetMutedCallAction"; +static NSString *const RNCallKitProviderDidReset = @"providerDidReset"; + + +@interface RNCallKit : RCTEventEmitter +@end + +@implementation RNCallKit +{ + CXCallController *callKitCallController; + CXProvider *callKitProvider; +} + +RCT_EXPORT_MODULE() + +- (NSArray *)supportedEvents +{ + return @[ + RNCallKitPerformAnswerCallAction, + RNCallKitPerformEndCallAction, + RNCallKitPerformSetMutedCallAction, + RNCallKitProviderDidReset + ]; +} + +// Configure CallKit +RCT_EXPORT_METHOD(setup:(NSDictionary *)options) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][setup] options = %@", options); +#endif + callKitCallController = [[CXCallController alloc] init]; + if (callKitProvider) { + [callKitProvider invalidate]; + } + callKitProvider = [[CXProvider alloc] initWithConfiguration:[self getProviderConfiguration: options]]; + [callKitProvider setDelegate:self queue:nil]; +} + +#pragma mark - CXCallController call actions + +// Display the incoming call to the user +RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString + handle:(NSString *)handle + hasVideo:(BOOL)hasVideo + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][displayIncomingCall] uuidString = %@", uuidString); +#endif + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + CXCallUpdate *callUpdate = [[CXCallUpdate alloc] init]; + callUpdate.remoteHandle + = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle]; + callUpdate.supportsDTMF = NO; + callUpdate.supportsHolding = NO; + callUpdate.supportsGrouping = NO; + callUpdate.supportsUngrouping = NO; + callUpdate.hasVideo = hasVideo; + + [callKitProvider reportNewIncomingCallWithUUID:uuid + update:callUpdate + completion:^(NSError * _Nullable error) { + if (error == nil) { + resolve(nil); + } else { + reject(nil, @"Error reporting new incoming call", error); + } + }]; +} + +// End call +RCT_EXPORT_METHOD(endCall:(NSString *)uuidString + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][endCall] uuidString = %@", uuidString); +#endif + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + CXEndCallAction *action = [[CXEndCallAction alloc] initWithCallUUID:uuid]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; + [self requestTransaction:transaction resolve:resolve reject:reject]; +} + +// Mute / unmute (audio) +RCT_EXPORT_METHOD(setMuted:(NSString *)uuidString + muted:(BOOL) muted + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][setMuted] uuidString = %@", uuidString); +#endif + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + CXSetMutedCallAction *action + = [[CXSetMutedCallAction alloc] initWithCallUUID:uuid muted:muted]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; + [self requestTransaction:transaction resolve:resolve reject:reject]; +} + +// Start outgoing call +RCT_EXPORT_METHOD(startCall:(NSString *)uuidString + handle:(NSString *)handle + video:(BOOL)video + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][startCall] uuidString = %@", uuidString); +#endif + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + CXHandle *callHandle + = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle]; + CXStartCallAction *action + = [[CXStartCallAction alloc] initWithCallUUID:uuid handle:callHandle]; + action.video = video; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; + [self requestTransaction:transaction resolve:resolve reject:reject]; +} + +// Indicate call failed +RCT_EXPORT_METHOD(reportCallFailed:(NSString *)uuidString + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + [callKitProvider reportCallWithUUID:uuid + endedAtDate:[NSDate date] + reason:CXCallEndedReasonFailed]; + resolve(nil); +} + +// Indicate outgoing call connected +RCT_EXPORT_METHOD(reportConnectedOutgoingCall:(NSString *)uuidString + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + [callKitProvider reportOutgoingCallWithUUID:uuid + connectedAtDate:[NSDate date]]; + resolve(nil); +} + +// Update call in case we have a display name or video capability changes +RCT_EXPORT_METHOD(updateCall:(NSString *)uuidString + options:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][updateCall] uuidString = %@ options = %@", uuidString, options); +#endif + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString]; + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + if (options[@"displayName"]) { + update.localizedCallerName = options[@"displayName"]; + } + if (options[@"hasVideo"]) { + update.hasVideo = [(NSNumber*)options[@"hasVideo"] boolValue]; + } + [callKitProvider reportCallWithUUID:uuid updated:update]; + resolve(nil); +} + +#pragma mark - Helper methods + +- (CXProviderConfiguration *)getProviderConfiguration:(NSDictionary* )settings +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][getProviderConfiguration]"); +#endif + CXProviderConfiguration *providerConfiguration + = [[CXProviderConfiguration alloc] initWithLocalizedName:settings[@"appName"]]; + providerConfiguration.supportsVideo = YES; + providerConfiguration.maximumCallGroups = 1; + providerConfiguration.maximumCallsPerCallGroup = 1; + providerConfiguration.supportedHandleTypes + = [NSSet setWithObjects:[NSNumber numberWithInteger:CXHandleTypeGeneric], nil]; + if (settings[@"imageName"]) { + providerConfiguration.iconTemplateImageData + = UIImagePNGRepresentation([UIImage imageNamed:settings[@"imageName"]]); + } + if (settings[@"ringtoneSound"]) { + providerConfiguration.ringtoneSound = settings[@"ringtoneSound"]; + } + return providerConfiguration; +} + +- (void)requestTransaction:(CXTransaction *)transaction + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][requestTransaction] transaction = %@", transaction); +#endif + [callKitCallController requestTransaction:transaction completion:^(NSError * _Nullable error) { + if (error == nil) { + resolve(nil); + } else { + NSLog(@"[RNCallKit][requestTransaction] Error requesting transaction (%@): (%@)", transaction.actions, error); + reject(nil, @"Error processing CallKit transaction", error); + } + }]; +} + +#pragma mark - CXProviderDelegate + +// Called when the provider has been reset. We should terminate all calls. +- (void)providerDidReset:(CXProvider *)provider { +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:providerDidReset]"); +#endif + [self sendEventWithName:RNCallKitProviderDidReset body:nil]; +} + +// Answering incoming call +- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:performAnswerCallAction]"); +#endif + [self sendEventWithName:RNCallKitPerformAnswerCallAction + body:@{ @"callUUID": action.callUUID.UUIDString }]; + [action fulfill]; +} + +// Call ended, user request +- (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:performEndCallAction]"); +#endif + [self sendEventWithName:RNCallKitPerformEndCallAction + body:@{ @"callUUID": action.callUUID.UUIDString }]; + [action fulfill]; +} + +// Handle audio mute from CallKit view +- (void)provider:(CXProvider *)provider performSetMutedCallAction:(CXSetMutedCallAction *)action { +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:performSetMutedCallAction]"); +#endif + [self sendEventWithName:RNCallKitPerformSetMutedCallAction + body:@{ @"callUUID": action.callUUID.UUIDString, + @"muted": [NSNumber numberWithBool:action.muted]}]; + [action fulfill]; +} + +// Starting outgoing call +- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:performStartCallAction]"); +#endif + [action fulfill]; + + // Update call info + CXCallUpdate *callUpdate = [[CXCallUpdate alloc] init]; + callUpdate.remoteHandle = action.handle; + callUpdate.supportsDTMF = NO; + callUpdate.supportsHolding = NO; + callUpdate.supportsGrouping = NO; + callUpdate.supportsUngrouping = NO; + callUpdate.hasVideo = action.isVideo; + [callKitProvider reportCallWithUUID:action.callUUID updated:callUpdate]; + + // Notify the system about the outgoing call + [callKitProvider reportOutgoingCallWithUUID:action.callUUID + startedConnectingAtDate:[NSDate date]]; +} + +// These just help with debugging + +- (void)provider:(CXProvider *)provider didActivateAudioSession:(AVAudioSession *)audioSession +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:didActivateAudioSession]"); +#endif +} + +- (void)provider:(CXProvider *)provider didDeactivateAudioSession:(AVAudioSession *)audioSession +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:didDeactivateAudioSession]"); +#endif +} + +- (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)action +{ +#ifdef DEBUG + NSLog(@"[RNCallKit][CXProviderDelegate][provider:timedOutPerformingAction]"); +#endif +} + +@end diff --git a/package.json b/package.json index 2361a6261..1f748b2a4 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "strophejs-plugins": "0.0.7", "styled-components": "1.3.0", "url-polyfill": "github/url-polyfill", + "uuid": "3.1.0", "xmldom": "0.1.27" }, "devDependencies": { diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index 2388687ed..4dd9101cf 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -7,6 +7,7 @@ import '../../authentication'; import { Platform } from '../../base/react'; import '../../mobile/audio-mode'; import '../../mobile/background'; +import '../../mobile/callkit'; import '../../mobile/external-api'; import '../../mobile/full-screen'; import '../../mobile/permissions'; diff --git a/react/features/mobile/callkit/CallKit.js b/react/features/mobile/callkit/CallKit.js new file mode 100644 index 000000000..b0c8b1bc8 --- /dev/null +++ b/react/features/mobile/callkit/CallKit.js @@ -0,0 +1,235 @@ +import { + NativeModules, + NativeEventEmitter, + Platform +} from 'react-native'; + +const RNCallKit = NativeModules.RNCallKit; + +/** + * Thin wrapper around Apple's CallKit functionality. + * + * In CallKit requests are performed via actions (either user or system started) + * and async events are reported via dedicated methods. This class exposes that + * functionality in the form of methods and events. One important thing to note + * is that even if an action is started by the system (because the user pressed + * the "end call" button in the CallKit view, for example) the event will be + * emitted in the same way as it would if the action originated from calling + * the "endCall" method in this class, for example. + * + * Emitted events: + * - performAnswerCallAction: The user pressed the answer button. + * - performEndCallAction: The call should be ended. + * - performSetMutedCallAction: The call muted state should change. The + * ancillary `data` object contains a `muted` attribute. + * - providerDidReset: The system has reset, all calls should be terminated. + * This event gets no associated data. + * + * All events get a `data` object with a `callUUID` property, unless stated + * otherwise. + */ +class CallKit extends NativeEventEmitter { + /** + * Initializes a new {@code CallKit} instance. + */ + constructor() { + super(RNCallKit); + this._setup = false; + } + + /** + * Returns True if the current platform is supported, false otherwise. The + * supported platforms are: iOS >= 10. + * + * @private + * @returns {boolean} + */ + static isSupported() { + return Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 10; + } + + /** + * Checks if CallKit was setup, and throws an exception in that case. + * + * @private + * @returns {void} + */ + _checkSetup() { + if (!this._setup) { + throw new Error('CallKit not initialized, call setup() first.'); + } + } + + /** + * Adds a listener for the given event. + * + * @param {string} event - Name of the event we are interested in. + * @param {Function} listener - Function which will be called when the + * desired event is emitted. + * @returns {void} + */ + addEventListener(event, listener) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return; + } + + this.addListener(event, listener); + } + + /** + * Notifies CallKit about an incoming call. This will display the system + * incoming call view. + * + * @param {string} uuid - Unique identifier for the call. + * @param {string} handle - Call handle in CallKit's terms. The room URL. + * @param {boolean} hasVideo - True if it's a video call, false otherwise. + * @returns {Promise} + */ + displayIncomingCall(uuid, handle, hasVideo = true) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.displayIncomingCall(uuid, handle, hasVideo); + } + + /** + * Request CallKit to end the call. + * + * @param {string} uuid - Unique identifier for the call. + * @returns {Promise} + */ + endCall(uuid) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.endCall(uuid); + } + + /** + * Removes a listener for the given event. + * + * @param {string} event - Name of the event we are no longer interested in. + * @param {Function} listener - Function which used to be called when the + * desired event was emitted. + * @returns {void} + */ + removeEventListener(event, listener) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return; + } + + this.removeListener(event, listener); + } + + /** + * Indicate CallKit that the outgoing call with the given UUID is now + * connected. + * + * @param {string} uuid - Unique identifier for the call. + * @returns {Promise} + */ + reportConnectedOutgoingCall(uuid) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.reportConnectedOutgoingCall(uuid); + } + + /** + * Indicate CallKit that the call with the given UUID has failed. + * + * @param {string} uuid - Unique identifier for the call. + * @returns {Promise} + */ + reportCallFailed(uuid) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.reportCallFailed(uuid); + } + + /** + * Tell CallKit about the audio muted state. + * + * @param {string} uuid - Unique identifier for the call. + * @param {boolean} muted - True if audio is muted, false otherwise. + * @returns {Promise} + */ + setMuted(uuid, muted) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.setMuted(uuid, muted); + } + + /** + * Prepare / initialize CallKit. This method must be called before any + * other. + * + * @param {Object} options - Initialization options. + * @param {string} options.imageName - Image to be used in CallKit's + * application button.. + * @param {string} options.ringtoneSound - Ringtone to be used for incoming + * calls. + * @returns {void} + */ + setup(options = {}) { + if (CallKit.isSupported()) { + options.appName = NativeModules.AppInfo.name; + RNCallKit.setup(options); + } + + this._setup = true; + } + + /** + * Indicate CallKit about a new outgoing call. + * + * @param {string} uuid - Unique identifier for the call. + * @param {string} handle - Call handle in CallKit's terms. The room URL in + * our case. + * @param {boolean} hasVideo - True if it's a video call, false otherwise. + * @returns {Promise} + */ + startCall(uuid, handle, hasVideo = true) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.startCall(uuid, handle, hasVideo); + } + + /** + * Updates an ongoing call's parameters. + * + * @param {string} uuid - Unique identifier for the call. + * @param {Object} options - Object with properties which should be updated. + * @param {string} options.displayName - Display name for the caller. + * @param {boolean} options.hasVideo - True if the call has video, false + * otherwise. + * @returns {Promise} + */ + updateCall(uuid, options) { + this._checkSetup(); + if (!CallKit.isSupported()) { + return Promise.resolve(); + } + + return RNCallKit.updateCall(uuid, options); + } +} + +export default new CallKit(); diff --git a/react/features/mobile/callkit/actionTypes.js b/react/features/mobile/callkit/actionTypes.js new file mode 100644 index 000000000..ce109d504 --- /dev/null +++ b/react/features/mobile/callkit/actionTypes.js @@ -0,0 +1,11 @@ +/** + * The type of redux action to set the CallKit event listeners. + * + * { + * type: _SET_CALLKIT_LISTENERS, + * listeners: Map|null + * } + * + * @protected + */ +export const _SET_CALLKIT_LISTENERS = Symbol('_SET_CALLKIT_LISTENERS'); diff --git a/react/features/mobile/callkit/index.js b/react/features/mobile/callkit/index.js new file mode 100644 index 000000000..200d25492 --- /dev/null +++ b/react/features/mobile/callkit/index.js @@ -0,0 +1,2 @@ +import './middleware'; +import './reducer'; diff --git a/react/features/mobile/callkit/middleware.js b/react/features/mobile/callkit/middleware.js new file mode 100644 index 000000000..5bd033fa9 --- /dev/null +++ b/react/features/mobile/callkit/middleware.js @@ -0,0 +1,194 @@ +/* @flow */ + +import uuid from 'uuid'; + +import { + APP_WILL_MOUNT, + APP_WILL_UNMOUNT, + appNavigate +} from '../../app'; +import { + CONFERENCE_FAILED, + CONFERENCE_LEFT, + CONFERENCE_WILL_JOIN, + CONFERENCE_JOINED +} from '../../base/conference'; +import { getInviteURL } from '../../base/connection'; +import { + SET_AUDIO_MUTED, + SET_VIDEO_MUTED, + isVideoMutedByAudioOnly, + setAudioMuted +} from '../../base/media'; +import { MiddlewareRegistry, toState } from '../../base/redux'; +import { _SET_CALLKIT_LISTENERS } from './actionTypes'; +import CallKit from './CallKit'; + +/** + * Middleware that captures several system actions and hooks up CallKit. + * + * @param {Store} store - The redux store. + * @returns {Function} + */ +MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { + const result = next(action); + + switch (action.type) { + case _SET_CALLKIT_LISTENERS: { + const { listeners } = getState()['features/callkit']; + + if (listeners) { + for (const [ event, listener ] of listeners) { + CallKit.removeEventListener(event, listener); + } + } + + if (action.listeners) { + for (const [ event, listener ] of action.listeners) { + CallKit.addEventListener(event, listener); + } + } + + break; + } + + case APP_WILL_MOUNT: { + CallKit.setup(); // TODO: set app icon. + const listeners = new Map(); + const callEndListener = data => { + const conference = getCurrentConference(getState); + + if (conference && conference.callUUID === data.callUUID) { + // We arrive here when a call is ended by the system, for + // for example when another incoming call is received and the + // user selects "End & Accept". + delete conference.callUUID; + dispatch(appNavigate(undefined)); + } + }; + + listeners.set('performEndCallAction', callEndListener); + + // Set the same listener for providerDidReset. According to the docs, + // when the system resets we should terminate all calls. + listeners.set('providerDidReset', callEndListener); + + const setMutedListener = data => { + const conference = getCurrentConference(getState); + + if (conference && conference.callUUID === data.callUUID) { + // Break the loop. Audio can be muted both from the CallKit + // interface and from the Jitsi Meet interface. We must keep + // them in sync, but at some point the loop needs to be broken. + // We are doing it here, on the CallKit handler. + const { muted } = getState()['features/base/media'].audio; + + if (muted !== data.muted) { + dispatch(setAudioMuted(Boolean(data.muted))); + } + + } + }; + + listeners.set('performSetMutedCallAction', setMutedListener); + + dispatch({ + type: _SET_CALLKIT_LISTENERS, + listeners + }); + break; + } + + case APP_WILL_UNMOUNT: + dispatch({ + type: _SET_CALLKIT_LISTENERS, + listeners: null + }); + break; + + case CONFERENCE_FAILED: { + const { callUUID } = action.conference; + + if (callUUID) { + CallKit.reportCallFailed(callUUID); + } + + break; + } + + case CONFERENCE_LEFT: { + const { callUUID } = action.conference; + + if (callUUID) { + CallKit.endCall(callUUID); + } + + break; + } + + case CONFERENCE_JOINED: { + const { callUUID } = action.conference; + + if (callUUID) { + CallKit.reportConnectedOutgoingCall(callUUID); + } + + break; + } + + case CONFERENCE_WILL_JOIN: { + const conference = action.conference; + const url = getInviteURL(getState); + const hasVideo = !isVideoMutedByAudioOnly({ getState }); + + // When assigning the call UUID, do so in upper case, since iOS will + // return it upper cased. + conference.callUUID = uuid.v4().toUpperCase(); + CallKit.startCall(conference.callUUID, url.toString(), hasVideo) + .then(() => { + const { room } = getState()['features/base/conference']; + + CallKit.updateCall(conference.callUUID, { displayName: room }); + }); + break; + } + + case SET_AUDIO_MUTED: { + const conference = getCurrentConference(getState); + + if (conference && conference.callUUID) { + CallKit.setMuted(conference.callUUID, action.muted); + } + + break; + } + + case SET_VIDEO_MUTED: { + const conference = getCurrentConference(getState); + + if (conference && conference.callUUID) { + const hasVideo = !isVideoMutedByAudioOnly({ getState }); + + CallKit.updateCall(conference.callUUID, { hasVideo }); + } + + break; + } + } + + return result; +}); + +/** + * Returns the currently active conference. + * + * @param {Function|Object} stateOrGetState - The redux state or redux's + * {@code getState} function. + * @returns {Conference|undefined} + */ +function getCurrentConference(stateOrGetState: Function | Object): ?Object { + const state = toState(stateOrGetState); + const { conference, joining } = state['features/base/conference']; + + return conference || joining; +} diff --git a/react/features/mobile/callkit/reducer.js b/react/features/mobile/callkit/reducer.js new file mode 100644 index 000000000..ce7231c3f --- /dev/null +++ b/react/features/mobile/callkit/reducer.js @@ -0,0 +1,17 @@ +import { ReducerRegistry } from '../../base/redux'; + +import { + _SET_CALLKIT_LISTENERS +} from './actionTypes'; + +ReducerRegistry.register('features/callkit', (state = {}, action) => { + switch (action.type) { + case _SET_CALLKIT_LISTENERS: + return { + ...state, + listeners: action.listeners + }; + } + + return state; +});