Skip to content
Platform docs
Auth, users, webhooks & more

Firebase Integration

Introduction

With FCM integration, we enable the ringing flow by handling push messages and displaying custom notifications.

Make sure you created Firebase provider and configured push notification manager as described in this section.

Add native permissions

Add the following permissions to allow camera, audio, and network access:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-feature android:name="android.hardware.camera"/>
    <uses-feature android:name="android.hardware.camera.autofocus"/>

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.CAMERA"/>
    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>

    <!-- Bluetooth permissions for audio routing -->
    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>

    <!-- Required for displaying call notifications -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

</manifest>

BLUETOOTH_CONNECT is a runtime permission from Android 12 (API 31), so declaring it is not enough — your app has to request it for Bluetooth audio devices to be usable and named. The SDK does not prompt for it. The Telecom integration treats it as optional; see Bluetooth device names.

Set android:launchMode to singleInstance

Update your MainActivity declaration in AndroidManifest.xml:

<manifest...>
     ...
   <application ...>
       <activity ...
          android:name=".MainActivity"
          android:launchMode="singleInstance">
        ...
   ...
 </manifest>

This ensures that tapping the push notification does not create a new instance of your app. Instead, it brings the existing instance to the foreground, preventing multiple screens from stacking up when accepting calls.

Android Telecom integration

The ringing flow can additionally register calls with the platform's Telecom stack through Jetpack Telecom. This does not replace the incoming call notification or the full-screen ringing UI — it layers system awareness on top of them, which gives the call proper audio focus, a place in the system call state, and lets it be answered or hung up from a paired watch, a car head unit or a Bluetooth headset.

Whether it is on depends on the Android version the app is running on:

Android version Default Why
17 and above On, opt out with enabled: false For an app targeting API 37, Android 17 will not play audio from a service that was started by a push and has no while-in-use capability, and that capability comes from the call being in the Telecom stack. Without it the incoming call ringtone is silently dropped.
Below 17 Off, opt in with enabled: true Ringing works without it, so it stays opt-in and nothing about an existing integration changes.

The default follows the Android version of the device, not your targetSdk, so it is also on for an app targeting a lower API that happens to run on Android 17. That is deliberately conservative — if you target below API 37 the restriction does not apply to you.

An explicit value always wins on both. Leave enabled unset to take the default for whichever version the app is running on:

pushConfiguration: const StreamVideoPushConfiguration(
  android: AndroidPushConfiguration(
    telecom: TelecomPushConfiguration(
      // Optional. Omit to follow the table above, `true` to force it on below
      // Android 17, `false` to opt out on Android 17 and above.
      enabled: true,
      // Optional. URI scheme for the call address reported to Telecom. Defaults to
      // your application id; `myapp` produces an address of `myapp:<callCid>`.
      schema: 'myapp',
    ),
  ),
),
Warning:

If you target API 37, opting out on Android 17 and above means the incoming call ringtone will not play while your app has no visible activity — which is the normal case for a call arriving by push. Only do this if you ring the user some other way. If you target below API 37 the restriction does not apply and opting out leaves ringing as it was.

Requirements

  • Android 8.0 (API 26) or newer. On older versions the integration is skipped.
  • The MANAGE_OWN_CALLS and FOREGROUND_SERVICE_PHONE_CALL permissions, which stream_video_push_notification already declares for you.
  • BLUETOOTH_CONNECT is optional — it only affects Bluetooth device names, see Bluetooth device names below.
  • Below Android 17 only, a telephony stack and a default dialer. Devices without them, such as some Wi-Fi-only tablets and TV devices, are skipped. From Android 17 the API level is the only requirement.

In every skipped case the ringing flow behaves exactly as it does with the integration turned off, so it is safe across a mixed device fleet.

The plugin compiles against Java 17 and adds androidx.core:core-telecom and org.jetbrains.kotlinx:kotlinx-coroutines-android to your build. Both are present whether or not the integration is turned on, so your Gradle build needs to run on JDK 17 or newer. core-telecom also merges BLUETOOTH_CONNECT and two components of its own — JetpackConnectionService and MuteStateReceiver — into your merged manifest.

Bluetooth device names

Jetpack Telecom uses BLUETOOTH_CONNECT to read the names of connected Bluetooth devices when it reports the available audio endpoints. The permission is declared for you, but from Android 12 (API 31) it is a runtime permission, so it only takes effect once your app requests it. The SDK does not prompt for it: when to ask is a decision for your app.

Info:

Nothing about ringing depends on this permission. CallsManager.addCall requires only MANAGE_OWN_CALLS, and Jetpack Telecom checks the Bluetooth grant before every use and falls back cleanly rather than failing. Without it, only the active Bluetooth device is surfaced and its name falls back to a generic default.

Outgoing calls

Incoming calls are registered for you when the push arrives. Outgoing calls are registered when you call startOutgoingCall, which the SDK does not call on your behalf:

// A UUID you generate and keep for the lifetime of the call — the native side
// identifies the call by it.
final uuid = const Uuid().v4();

await StreamVideo.instance.pushNotificationManager?.startOutgoingCall(
  uuid: uuid,
  callCid: call.callCid.value,
  callerName: 'Jane Doe',
  hasVideo: true,
);

Calls ended from outside your app

Once a call is in the Telecom stack it can be hung up from a paired watch, a headset or a car head unit. Those arrive as ActionCallEnded ringing events carrying CallData.endedBySystem, and observeCoreRingingEvents applies them for you.

If you handle ringing events yourself, only act on an ended event on Android when that flag is set. A plain ended event on Android also means the incoming call notification was dismissed, which is not a hang-up:

streamVideo.onRingingEvent<ActionCallEnded>((event) {
  if (CurrentPlatform.isAndroid && !event.data.endedBySystem) return;

  // The call was really ended, from this device or from a connected one.
});
Info:

If your app does not use the ringing flow at all, the permissions the plugin declares can be stripped from the merged manifest by adding xmlns:tools="http://schemas.android.com/tools" to your <manifest> tag and <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" tools:node="remove"/> to it. This turns the Telecom integration off on every Android version, so do it only if you never ring users.

Handling Ringing events (common for iOS and Android)

Note:

Ringing events are exposed by the stream_video_push_notification package to handle incoming calls on both iOS and Android. It is important to handle these events to ensure a seamless calling experience regardless of which provider is used for push.

In a high-level widget in your app, add this code to listen to Ringing events:

import 'package:rxdart/rxdart.dart';

final _compositeSubscription = CompositeSubscription();

@override
void initState() {
  ...
  _observeRingingEvents()
}

void _observeRingingEvents() {
  final streamVideo = StreamVideo.instance;

  // You can use our helper method to observe core Ringing events
  // It will handled call accepted, declined and ended events
  _compositeSubscription.add(
      streamVideo.observeCoreRingingEvents(
        onCallAccepted: (callToJoin) {
            // Replace with navigation flow of your choice
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => CallScreen()),
            );
        },
      ),
    );

  // Or you can handle them by yourself, and/or add additional events such as handling mute events from CallKit (iOS)
  // _compositeSubscription.add(streamVideo.onRingingEvent<ActionCallToggleMute>(_onCallToggleMute));
}

@override
void dispose() {
  // ...
  _compositeSubscription.cancelAll();
}

If you need to manage the ringing flow call, you can use the StreamVideo.pushNotificationManager. As an example, let's say you want to end all calls, you can end them this way:

StreamVideo.instance.pushNotificationManager?.endAllCalls();

Listen to push notifications

In a high-level widget in your app, add this code to listen to FCM messages:

import 'package:rxdart/rxdart.dart';

final _compositeSubscription = CompositeSubscription();

@override
void initState() {
  ...
  _observeFcmMessages()
}

_observeFcmMessages() {
  _compositeSubscription.add(
      FirebaseMessaging.onMessage.listen(_handleRemoteMessage),
  );
}

Future<void> _handleRemoteMessage(RemoteMessage message) async {
  await StreamVideo.instance.handleRingingFlowNotifications(message.data);
}

@override
void dispose() {
  // ...
  _compositeSubscription.cancelAll();
}

The handleRingingFlowNotifications() method will show custom notification indicating ringing call. It will also handle call.missed push by showing dedicated notification if you want to handle it by yourself set handleMissedCall parameter to false.

Handle push in background and terminated state

When you app is in the background special handling is required. We need to register a handler method that will be called by system when push is received even when app is not running.

Note:

We recommend storing user credentials locally when the user logs in so you can automatically set up the user when a push notification is received in background.

Add the following code as top lever functions (for example on top of your main.dart file):

// As this runs in a separate isolate, we need to setup the app again.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Initialise Firebase
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  try {
    // Get stored user credentials
    final tutorialUser = await AppInitializer.getStoredUser();
    if (tutorialUser == null) return;

    // Use the `create` factory to create an instance separate from the `StreamVideo.instance` singleton
    final streamVideo = StreamVideo.create(
      ...,
      // Make sure you initialise push notification manager
      pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
        iosPushProvider: const StreamVideoPushProvider.apn(
          name: 'your-ios-provider-name',
        ),
        androidPushProvider: const StreamVideoPushProvider.firebase(
          name: 'your-fcm-provider',
        ),
        pushConfiguration: const StreamVideoPushConfiguration(
          ios: IOSPushConfiguration(iconName: 'IconMask'),
        ),
      ),
    )..connect();

    // Ensure proper handling of Ringing events during the ringing
    final subscription = streamVideo.observeCoreRingingEventsForBackground();

    // Dispose this instance after ringing is resolved
    streamVideo.disposeAfterResolvingRinging(
      disposingCallback: () => subscription?.cancel(),
    );

    // Handle the push notification
    await streamVideo.handleRingingFlowNotifications(message.data);
  } catch (e, stk) {
    debugPrint('Error handling remote message: $e');
    debugPrint(stk.toString());
  }
}

Now register this handler in FirebaseMessaging instance. You can do it for example inside the _observeFcmMessages() method we created in a previous step:

_observeFcmMessages() {
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  _compositeSubscription.add(
      FirebaseMessaging.onMessage.listen(_handleRemoteMessage),
  );
}

In case the call was accepted when the app was terminated we also need to consume it.

In a high-level widget, add this method and call it from the initState() method:

@override
void initState() {
  //...
  _tryConsumingIncomingCallFromTerminatedState();
}

void _tryConsumingIncomingCallFromTerminatedState() {
  // This is only relevant for Android.
  if (CurrentPlatform.isIos) return;

  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
    StreamVideo.instance.consumeAndAcceptActiveCall(
      onCallAccepted: (callToJoin) {
        // Replace with navigation flow of your choice
        Navigator.push(
          context,
          MaterialPageRoute(builder: (context) => CallScreen()),
        );
      },
    );
  });
}

Request notification permission from user

For Android 13+ you need to request the POST_NOTIFICATIONS permission. You can do it using the permission_handler package.

Remember to follow official best practices (especially showing prompt before the request).

Make sure permission to send full-screen notifications is granted

For Android 14+ on some devices, the full-screen intent permission might not be granted, preventing the ringing notification from appearing when the screen is locked.

We expose a dedicated method to make sure this permission is granted:

StreamVideoPushNotificationManager.ensureFullScreenIntentPermission();

In case it is not granted, the user will be taken to the app's settings page to enable full-screen notifications.

You should now be able to receive a ringing call on Android.

To test this:

  • Create a ringing call on another device (as describe in previous section).
  • Add the ID of a user logged into the Android device to the memberIds array in the call.getOrCreate(ringing: true, memberIds: [{ID}]) method.
  • You should see the custom ringing notification show on the Android device.

If you encounter any issues, refer to the Troubleshooting section for solutions to common mistakes.

Add Chat to my app: getstream.io/SKILL.md

The fastest way to build with Stream. Start a new project or improve an existing one. Full CLI and documentation integration out of the box.


Ask your agent:

/stream Build me a Social App with Feeds and Moderation.
/stream Any livestream calls running?
/stream Video Flutter v1: <Your Question>