Skip to content
Logo Theodo

Using Native Incoming Call UI for your React Native App (How I Wrote My First Native Module)

Francisco Costa9 min read

Laptop with Code

A story about my struggles implementing native call display functionality and how this led me to writting my own native module for android. Suitable for developers who are interested in how to get started with native modules for android within React Native or implementing native UI for incoming calls.

TL;DR

A project I was working on required native call UI to be implemented so that when users received a call notification from firebase their phone would ring as if they were receiving a native call. This was a really interesting feature to build but it did not come without difficulty. The library react-native-callkeep was used to implement this solution, this library acts as a fully managed connection service for implementing call UI using react native. However, the project had its own in-call UI we wanted to use, therefore we only wanted to use the incoming call functionality of callkeep. This solution seemed to work fine for iOS with the base recommended implementation, with the downside that it didn’t work as smoothly for the latest android versions we wanted to run (android with a minimum api of 27). To get android working properly for these versions I had to write a native module which mimicked the backToForeground function from react-native-callkeep.

Native call UI trying to be implemented:

Native Incoming Display for Android
Native Incoming Display for IOS

Intro

When I first realised the project I was working on would need an incoming call display that transitions into a React Native app I (foolishly) didn’t think much of it, and assumed a simple library would solve most of my worries. As a team we decided to use the react-native-callkeep library which seemed to have all of the functionality required, which was to be able to display incoming call UI after a notification is received.

This implementation however, started to show a few issues when testing functionality on the latest android versions. These issues then led me down a track which ended with implementing a native android module. Writing this native module eventually did result in the desired functionality, although, to properly explain my decision into doing this I need to start from the beginning.

Using react-native-callkeep

The base implementation of callkeep is pretty straight forward and works well out of the box, when initially experimenting with it, the only problem I ran into was that it wouldn’t display the native incoming call UI on certain android emulators. Switching from an android 11 emulator to an android 10Q emulator seemed to do the trick (note: this was only a problem for the emulator as the same functionality seemed to work on a real android 11 phone).

After following the basic installation guide (Found here), I would recommend setting up a callkeep class in your project. The basics to get the display incoming call UI to work for my project was:

import RNCallKeep, { AnswerCallPayload } from 'react-native-callkeep';

type DidDisplayIncomingCallArgs = {
  error: string;
  callUUID: string;
  handle: string;
  localizedCallerName: string;
  hasVideo: string;
  fromPushKit: string;
  payload: string;
};

export class CallKeep {
  private static instance: CallKeep;
  private callId: string;
  private callerName: string;
  private callerId: string;
  private isAudioCall: string | undefined;
  private deviceOS: string;
  private endCallCallback: Function | undefined;
  private muteCallback: Function | undefined;
  public IsRinging: boolean = false;

  constructor(
    callId: string,
    callerName: string,
    callerId: string,
    deviceOS: string,
    isAudioCall?: string
  ) {
    this.callId = callId;
    this.callerName = callerName;
    this.callerId = callerId;
    this.isAudioCall = isAudioCall;
    this.deviceOS = deviceOS;

    CallKeep.instance = this;
    this.setupEventListeners();
  }

  public static getInstance(): CallKeep {
    return CallKeep.instance;
  }

  endCall = () => {

    RNCallKeep.endCall(this.callId);

    if (this.endCallCallback) {
      this.endCallCallback();
    }

    this.removeEventListeners();
  };

  displayCallAndroid = () => {
    this.IsRinging = true;
    RNCallKeep.displayIncomingCall(
      this.callId,
      this.callerName,
      this.callerName,
      'generic'
    );
    setTimeout(() => {
      if (this.IsRinging) {
        this.IsRinging = false;
        // 6 = MissedCall
        // https://github.com/react-native-webrtc/react-native-callkeep#constants
        RNCallKeep.reportEndCallWithUUID(this.callId, 6);
      }
    }, 15000);
  };

  answerCall = ({ callUUID }: AnswerCallPayload) => {

    this.IsRinging = false;

    navigate('somewhere'); // navigated to call screen in our app
  };

  didDisplayIncomingCall = (args: DidDisplayIncomingCallArgs) => {
    if (args.error) {
      logError({
        message: `Callkeep didDisplayIncomingCall error: ${args.error}`,
      });
    }

    this.IsRinging = true;
    RNCallKeep.updateDisplay(
      this.callId,
      `${this.callerName}`,
      this.callerId
    );

    setTimeout(() => {
      if (this.IsRinging) {
        this.IsRinging = false;
        // 6 = MissedCall
        // https://github.com/react-native-webrtc/react-native-callkeep#constants
        RNCallKeep.reportEndCallWithUUID(this.callId, 6);
      }
    }, 15000);
  };

  private setupEventListeners() {
    RNCallKeep.addEventListener('endCall', this.endCall);
    RNCallKeep.addEventListener(
      'didDisplayIncomingCall',
      this.didDisplayIncomingCall
    );
  }

  private removeEventListeners() {
    RNCallKeep.removeEventListener('endCall');
    RNCallKeep.removeEventListener('didDisplayIncomingCall');
    this.endCallCallback = undefined;
  }
}

This setup will get you the basics that you will need to handle displaying the incoming call UI along with a base setup for answering and ending calls. The key functions here to get only the incoming call UI working is the RNCallKeep.displayIncomingCall and RNCallKeep.endCall.

Initial problems with android

After my initial experiments with callkeep it seemed that IOS worked fine out of the box, however, there were some difficulties to get the same functionality working for android. The difficulties I came across with android are summarised below:

  1. Emulator was not displaying the incoming call UI when correct command was called (RNCallKeep.displayIncomingCall)

    • After experimenting with different emulators and getting the functionality to work, this led me to the conclusion that the android 11 emulator was not set up correctly to use this library.
    • Changing to an android 10Q api 29 android fixed this issue, and the incoming call UI was successfully displayed
  2. Answering the call from the incoming UI display on android was not navigating me to the app, the UI acted as if I had never answered the call.

    Gif of android native Call UI not navigating from background mode
    • Solving this issue required some experimenting, but in the end was solved by calling RNCallKeep.endCall on the callAnswered listener and then navigating to the correct page on our app.
    • Calling the RNCallKeep.endCall seemed to prompt android to stop displaying the UI and run the rest of the commands in the answered call listener function
    • This step needed to be done on android only.
  3. When a call was received, if android was locked, the user would not be navigated to the app but would instead be stuck at their home screen.

    Gif of android native Call UI not navigating from locked mode
    • The functionality required here instead of this issue would be for the user to be prompted to unlock their phone, and then be navigated to the app.
    • This condition was a bit trickier and required me to dive into the realms of native app development as discussed below.

Writing a native module for android

Fulfilling the functionality required by the lockscreen took some further investigation, the callkeep library instructs you to call the android only function RNCallKeep.backToForeground to bring the react-native app to the foreground. This seemed to work while the app was in background mode but not while the app was in a lockscreen state. Looking into this function I found that it was using some java functions that were deprecated for the android versions necessary for our project. This led me to recreate this backToForeground function natively with an updated set of java functions that would work for the required versions.

Since I am working in React Native to write native code and call it from typescript/javascript I needed to use the React Native bridge. The android bridging documentation from React Native (Found here) has all of the necessary steps required to set up the java module and call it from within your app. Below is an example of the java module I created to start the app activity and request the user to unlock their phone.

package com.ExampleProject;

import android.app.Activity;
import android.app.ActivityManager;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.WindowManager;

import androidx.annotation.RequiresApi;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class CallkeepHelperModule extends ReactContextBaseJavaModule {

  private static ReactApplicationContext reactContext;

  CallkeepHelperModule(ReactApplicationContext context) {
    super(context);
    reactContext = context;
  }

  @Override
  public String getName() {
    return "CallkeepHelperModule";
  }

  @RequiresApi(api = Build.VERSION_CODES.O_MR1)
  @ReactMethod
  public void dismissKeyguard(Activity activity){
    KeyguardManager keyguardManager = (KeyguardManager) reactContext.getSystemService(
      Context.KEYGUARD_SERVICE
    );
    boolean isLocked = keyguardManager.isKeyguardLocked();
    if (isLocked) {
      Log.d("CallkeepHelperModule", "lockscreen");
      keyguardManager.requestDismissKeyguard(
        activity,
        new KeyguardManager.KeyguardDismissCallback() {
          @Override
          public void onDismissError() {
            Log.d("CallkeepHelperModule", "onDismissError");
          }

          @Override
          public void onDismissSucceeded() {
            Log.d("CallkeepHelperModule", "onDismissSucceeded");
          }

          @Override
          public void onDismissCancelled() {
            Log.d("CallkeepHelperModule", "onDismissCancelled");
          }
        }
      );
    } else {
      Log.d("CallkeepHelperModule", "unlocked");
    }
  }

  @ReactMethod
  public void startActivity() {
    Log.d("CallkeepHelperModule", "start activity");
    Context context = getAppContext();
    String packageName = context.getApplicationContext().getPackageName();
    Intent focusIntent = context.getPackageManager().getLaunchIntentForPackage(packageName).cloneFilter();
    Activity activity = getCurrentActivity();
    boolean isRunning = activity != null;

    if(isRunning){
      Log.d("CallkeepHelperModule", "activity is running");
      focusIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
      activity.startActivity(focusIntent);
      dismissKeyguard(activity);
    } else {
      Log.d("CallkeepHelperModule", "activity is not running, starting activity");
      focusIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
      context.startActivity(focusIntent);
    }
  }

  private Context getAppContext() {
    return this.reactContext.getApplicationContext();
  }
}

The main function of this module is startActivity, which when called gets the context for the React app, if the app is running the function brings the app to the foreground and checks if the keyguard needs to be dismissed, otherwise if the app is not running this function starts the apps activity as a new task. The keyguard is then dismissed by calling the dismissKeyguard function which uses the KeyguardManager to check if the android is locked or unlocked, and asks the user to dismiss the keyguard if the phone is locked.

Putting all of these steps together I could then call the native module in my react app whenever a call was accepted from the incoming UI using the answerCall listener function like so:

import { NativeModules } from "react-native";
import RNCallKeep from "react-native-callkeep";

answerCall = ({ callUUID }: AnswerCallPayload) => {
  if (this.deviceOS === "android") {
    const { CallkeepHelperModule } = NativeModules;
    CallkeepHelperModule.startActivity();
    RNCallKeep.endCall(this.callId);
  }
  this.IsRinging = false;
  navigate("somewhere");
};

Conclusion

Callkeep was able to be implemented as an incoming UI solution only, in both IOS and android platforms. On android, native code was written so that answering the app from the lockscreen would start the react native app and navigate you to the correct screen. After these adjustments the React Native callkeep library ended up being a great fit for this project, performing all the required functionality. I hope this article is able to help anyone deciding between solutions to display incoming call UI, and hopefully cut down on some research and debugging time. I will finally leave you with a gif of the working functionality in all its glory, happy coding!

Gif of android native Call UI working from locked modeGif of android native Call UI working from background mode

Liked this article?