ConnectyCube / connectycube-reactnative-samples

Chat and Video Chat code samples for React Native, ConnectyCube
https://connectycube.com
Apache License 2.0
124 stars 111 forks source link

RNVideoChat: PushNotificationsService, VoIP push notifications, Call Kit #301

Closed DaveLomber closed 1 year ago

DaveLomber commented 1 year ago

Overview

A new RNVideoChat code sample which is a replacement to old RNVideoChat

What's done:

Documentation

Basic documentation is available at https://developers.connectycube.com/reactnative/videocalling and will de advanced once the MR is merged.

In addition, we will cover the CallKit related operations here.

The goal of CallKit is to receive call requests when an app is in background or killed state. For iOS we will use CallKit and for Android we will use standard capabilities.

Initiate a call

When initiate a call via session.call(), additionally we need to send a push notification (standard for Android user and VOIP for iOS). This is required to be able to receive an incoming call request when an app is in background or killed state.

The following request will initiate a standard push notification for Android and a VOIP push notification for iOS:

const callType = "video" // "voice"
const callInitiatorName = "..."
const callOpponentsIds = [...]

const pushParams = {
    message: `Incoming call from ${callInitiatorName}`,
    ios_voip: 1,
    handle: callInitiatorName,
    initiatorId: callSession.initiatorID,
    opponentsIds: callOpponentsIds.join(","),
    uuid: callSession.ID,
    callType
};
PushNotificationsService.sendPushNotification(callOpponentsIds, pushParams);

...

//  PushNotificationsService:

sendPushNotification(recipientsUsersIds, params) {
  const payload = JSON.stringify(params);
  const pushParameters = {
    notification_type: "push",
    user: { ids: recipientsUsersIds }, 
    environment: __DEV__ ? "development" : "production",
    message: ConnectyCube.pushnotifications.base64Encode(payload),
  };

  ConnectyCube.pushnotifications.events.create(pushParameters)
    .then(result => {
      console.log("[PushNotificationsService][sendPushNotification] Ok");
    }).catch(error => {
      console.warn("[PushNotificationsService][sendPushNotification] Error", error);
    });
}

We recommend to simply copy-past the entire src/services/pushnotifications-service.js file into your app.

For push notifications we use react-native-notifications lib.

Receive call request in background/killed state

The goal of CallKit is to receive call requests when an app is in background or killed state. For iOS we will use CallKit and for Android we will use standard capabilities.

Android

First of all, we need to setup callbacks to receive push notification - in background and in killed state (there is a dedicated doc regarding how to setup a callback to receive pushes in killed state https://developers.connectycube.com/reactnative/push-notifications?id=receive-pushes-in-killed-state-android):

import invokeApp from 'react-native-invoke-app';

class PushNotificationsService {
  constructor() {
    console.log("[PushNotificationsService][constructor]");
    this._registerBackgroundTasks();
  }

  init() {

    Notifications.events().registerNotificationReceivedBackground(async (notification, completion) => {
      console.log("[PushNotificationService] Notification Received - Background", notification.payload, notification?.payload?.message);

      if (Platform.OS === 'android') {
        if (await PermissionsService.isDrawOverlaysPermisisonGranted()) {
          invokeApp();

          const dummyCallSession = {
            initiatorID: notificationBundle.initiatorId,
            opponentsIDs: notificationBundle.opponentsIds.split(","),
            ID: notificationBundle.uuid
          }
          store.dispatch(setCallSession(dummyCallSession, true, true));
        } else {
          PushNotificationsService.displayNotification(notification.payload);
        }
      }

      // Calling completion on iOS with `alert: true` will present the native iOS inApp notification.
      completion({alert: true, sound: true, badge: false});
    });
  }

  _registerBackgroundTasks() {
    if (Platform.OS === 'ios') {
      return;
    }

    const { AppRegistry } = require("react-native");

    // https://reactnative.dev/docs/headless-js-android
    //
    AppRegistry.registerHeadlessTask(
      "JSNotifyWhenKilledTask",
      () => {
        return async (notificationBundle) => {
          console.log('[JSNotifyWhenKilledTask] notificationBundle', notificationBundle);

          if (await PermissionsService.isDrawOverlaysPermisisonGranted()) {
            invokeApp();

            const dummyCallSession = {
              initiatorID: notificationBundle.initiatorId,
              opponentsIDs: notificationBundle.opponentsIds.split(","),
              ID: notificationBundle.uuid
            }
            store.dispatch(setCallSession(dummyCallSession, true, true));
          } else {
            PushNotificationsService.displayNotification(notificationBundle);
          }
        }
      },
    );
  }
}

What we do is we simply open app (bringing the app to foreground) once a push re incoming call is received and display an incoming call screen. This is done via react-native-invoke-app lib.

Also, we have PermisisonsService to check if a user granted a DrawOverlays permission to make the switch to foreground possible:

import { isOverlayPermissionGranted, requestOverlayPermission } from 'react-native-can-draw-overlays';
import { Alert } from "react-native";

class PermisisonsService {
  async checkAndRequestDrawOverlaysPermission() {
    if (Platform.OS !== 'android') {
      return true;
    }

    const isGranted = await this.isDrawOverlaysPermisisonGranted();
    if (!isGranted) {
      Alert.alert(
        "Permission required",
        "For accepting calls in background you should provide access to show System Alerts from in background. Would you like to do it now?",
        [
          {
            text: "Later",
            onPress: () => {},
            style: "cancel"
          },
          { text: "Request", onPress: () => {
            this.requestOverlayPermission();
          }}
        ]
      );

    }
  }

  async isDrawOverlaysPermisisonGranted() {
    const isGranted = await isOverlayPermissionGranted();
    console.log("[PermisisonsService][isDrawOverlaysPermisisonGranted]", isGranted);
    return isGranted;
  }

  async requestOverlayPermission() {
    const granted = await requestOverlayPermission();
    console.log("[PermisisonsService][requestOverlayPermission]", granted);
    return granted;
  }
}

const permisisonsService = new PermisisonsService();
export default permisisonsService;

iOS

For iOS we need to setup CallKit. For this a react-native-callkeep library will be used.

All the logic is presented in call-service.js file:

// CallService

import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
import { getApplicationName } from 'react-native-device-info';
import RNUserdefaults from '@tranzerdev/react-native-user-defaults';

initCallKit() {
    if (Platform.OS !== 'ios') {
      return;
    }

    const options = {
      ios: {
        appName: getApplicationName(),
        includesCallsInRecents: false,
      }
    };

    RNCallKeep.setup(options).then(accepted => {
      console.log('[CallKitService][setup] Ok');
    }).catch(err => {
      console.error('[CallKitService][setup] Error:', err.message);
    });

    // Add RNCallKeep Events
    // RNCallKeep.addEventListener('didReceiveStartCallAction', this.didReceiveStartCallAction);
    RNCallKeep.addEventListener('answerCall', this.onAnswerCallAction);
    RNCallKeep.addEventListener('endCall', this.onEndCallAction);
    RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.onToggleMute);
    RNCallKeep.addEventListener('didChangeAudioRoute', this.onChangeAudioRoute);
    RNCallKeep.addEventListener('didLoadWithEvents', this.onLoadWithEvents);
}

onAnswerCallAction = (data) => {
    console.log('[CallKitService][onAnswerCallAction]', data);

    // let { callUUID } = data;

    // Called when the user answers an incoming call via Call Kit
    if (!this.isAccepted) { // by some reason, this event could fire > 1 times
      this.acceptCall({}, true);
    }
};

onEndCallAction = async (data) => {
    console.log('[CallKitService][onEndCallAction]', data);

    let { callUUID } = data;

    if (this.callSession) {
      if (this.isAccepted) {
        this.rejectCall({}, true);
      } else {
        this.stopCall({}, true);
      }
    } else {
      const voipIncomingCallSessions = await RNUserdefaults.get("voipIncomingCallSessions");
      if (voipIncomingCallSessions) {
        const sessionInfo = voipIncomingCallSessions[callUUID];
        if (sessionInfo) {
          const initiatorId = sessionInfo["initiatorId"];

          // most probably this is a call reject, so let's reject it via HTTP API
          ConnectyCube.videochat.callRejectRequest({
            sessionID: callUUID,
            platform: Platform.OS,
            recipientId: initiatorId
          }).then(res => {
            console.log("[CallKitService][onEndCallAction] [callRejectRequest] done")
          });
        }
      }
    }
};

onToggleMute = (data) => {
    console.log('[CallKitService][onToggleMute]', data);

    let { muted, callUUID } = data;
    // Called when the system or user mutes a call

    this.muteMicrophone(muted, true)
};

onChangeAudioRoute = (data) => {
    console.log('[CallKitService][onChangeAudioRoute]', data);

    const output = data.output;
    // could be Speaker or Receiver
};

onLoadWithEvents = (events) => {
    console.log('[CallKitService][onLoadWithEvents]', events);

    // `events` is passed as an Array chronologically, handle or ignore events based on the app's logic
    // see example usage in https://github.com/react-native-webrtc/react-native-callkeep/pull/169 or https://github.com/react-native-webrtc/react-native-callkeep/pull/20
};

Also, when perform any operations e.g. start call, accept, reject, stop etc, we need to report back to CallKit lib - to have both app UI and CallKit UI in sync:

RNCallKeep.startCall(callUUID, handle, contactIdentifier, handleType, hasVideo);

...

RNCallKeep.answerIncomingCall(callUUID);

...

RNCallKeep.rejectCall(callUUID);

...

RNCallKeep.endCall(callUUID);

...

RNCallKeep.setMutedCall(callUUID, isMuted);

...

RNCallKeep.reportEndCallWithUUID(callUUID, reason); 

For the callUUID we will be using call's session.ID.

The last point is to do the needed changes at iOS native code.

When receive a VOIP push notification in background/killed state, we must immediately display an incoming CallKit screen. Otherwise, the app will be banned with an error. To do so, the following changes in AppDelegate.mm should be done:

#import "RNNotifications.h"
#import "RNEventEmitter.h"
#import "RNCallKeep.h"
...

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{

...

  [RNNotifications startMonitorNotifications];
 [RNNotifications startMonitorPushKitNotifications];

  [[NSNotificationCenter defaultCenter] addObserver:self
                                           selector:@selector(handlePushKitNotificationReceived:)
                                               name:RNPushKitNotificationReceived
                                             object:nil];
  // cleanup
  [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"voipIncomingCallSessions"];

  return YES;
}

...

- (void)handlePushKitNotificationReceived:(NSNotification *)notification {
  UIApplicationState state = [[UIApplication sharedApplication] applicationState];

  if (state == UIApplicationStateBackground || state == UIApplicationStateInactive) {

    // save call info to user defaults
    NSMutableDictionary *callsInfo = [[[NSUserDefaults standardUserDefaults] objectForKey:@"voipIncomingCallSessions"] mutableCopy];
    if (callsInfo == nil) {
      callsInfo = [NSMutableDictionary dictionary];
    }
    [callsInfo setObject:@{
      @"initiatorId": notification.userInfo[@"initiatorId"],
      @"opponentsIds": notification.userInfo[@"opponentsIds"],
      @"handle": notification.userInfo[@"handle"],
      @"callType": notification.userInfo[@"callType"]
    } forKey:notification.userInfo[@"uuid"]];
    [[NSUserDefaults standardUserDefaults] setObject:callsInfo forKey:@"voipIncomingCallSessions"];

    // show CallKit incoming call screen
    [RNCallKeep reportNewIncomingCall: notification.userInfo[@"uuid"]
                               handle: notification.userInfo[@"handle"]
                           handleType: @"generic"
                             hasVideo: [notification.userInfo[@"callType"] isEqual: @"video"]
                  localizedCallerName: notification.userInfo[@"handle"]
                      supportsHolding: YES
                         supportsDTMF: YES
                     supportsGrouping: YES
                   supportsUngrouping: YES
                          fromPushKit: YES
                              payload: notification.userInfo
                withCompletionHandler: nil];
  } else {
    // when an app is in foreground -> will show the in-app UI for incoming call
  }
}