---
sidebar_position: 3
title: VoIP Push Notifications
description: Setup FCM and APNs VoIP push notifications for incoming calls in the Firetell Flutter SDK.
---

# VoIP Push Notifications

This guide covers how to wake your Flutter app for incoming calls when it is in the background or completely terminated, using Firebase Cloud Messaging (FCM) on Android and APNs VoIP push on iOS.

---

## Architecture

```
Caller dials → Firetell Backend → Push Notification
                                       │
                    ┌──────────────────┴──────────────────┐
                    ▼                                      ▼
              FCM (Android)                          APNs VoIP (iOS)
                    │                                      │
                    ▼                                      ▼
            Background Handler                      PushKit Delegate
                    │                                      │
                    ▼                                      ▼
       flutter_callkit_incoming               Native CallKit UI
       (full-screen notification)             (lock screen ring)
                    │                                      │
                    └──────────────────┬──────────────────┘
                                       ▼
                              User answers / declines
                                       │
                    ┌──────────────────┴──────────────────┐
                    ▼                                      ▼
                 Answer:                              Decline:
         Call(ws_url, call_token)               rejectViaHttp(call_token)
         → connectSignaling()                  → POST /calls/{id}/reject
         → accept()                            → ~50ms ✅
         → audio flows ✅
```

**Key insight:** The push payload contains `ws_url` and `call_token` — everything needed to connect the call **directly**, without initializing a full `FiretellClient` or fetching workspace metadata.

---

## Setup

### 1. Firebase Configuration

```bash
flutter pub add firebase_core firebase_messaging
flutterfire configure
```

### 2. Register Push Tokens

Call this **after** `client.ready` resolves (after login):

```dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:firetell_flutter_sdk/firetell_flutter_sdk.dart';

Future<void> registerPushTokens(FiretellClient client) async {
  // Request permission (required on iOS)
  await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
    criticalAlert: true,
  );

  final deviceId = await DeviceIdHelper.getOrCreate();
  final isIOS = !kIsWeb && Platform.isIOS;

  // 1. Obtain VoIP Push Token
  // - iOS: Apple PushKit token (raw 64-character hex string) via flutter_callkit_incoming
  // - Android: FCM High-Priority Data message token
  String? voipToken;
  if (isIOS) {
    try {
      final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
      if (token.isNotEmpty) voipToken = token;
    } catch (e) {
      debugPrint('Failed to get iOS VoIP token: $e');
    }
  } else {
    voipToken = await FirebaseMessaging.instance.getToken();
  }

  // Register VoIP Push Token (triggers call.ring pushes)
  if (voipToken != null && voipToken.isNotEmpty) {
    await PushTokenService.registerVoipPushToken(
      baseUrl: client.baseUrl,
      jwt: client.jwt,
      pushToken: voipToken,
      deviceId: deviceId,
      platform: isIOS ? 'ios' : 'android',
    );
  }

  // 2. Register Notification Token (for call.canceled / call.ended background dismissals)
  final fcmToken = await FirebaseMessaging.instance.getToken();
  if (fcmToken != null && fcmToken.isNotEmpty) {
    await PushTokenService.registerNotificationPushToken(
      baseUrl: client.baseUrl,
      jwt: client.jwt,
      notificationToken: fcmToken,
      deviceId: deviceId,
      platform: isIOS ? 'ios' : 'android',
    );
  }
}
```

### 3. Background Message Handler

Add a **top-level function** in `main.dart`:

```dart
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();

  final event = message.data['event'] as String?;

  if (event == 'call.ring') {
    final params = CallRingParams.fromFcmData(message.data);
    await _showNativeIncomingCall(params);
  } else if (event == 'call.canceled' || event == 'call.ended') {
    final callId = message.data['call_id'] as String?;
    if (callId != null) {
      await FlutterCallkitIncoming.endCall(callId);
    }
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
  runApp(const MyApp());
}
```

### 4. Show Native Incoming Call UI

```dart
Future<void> _showNativeIncomingCall(CallRingParams params) async {
  await FlutterCallkitIncoming.showCallkitIncoming(CallKitParams(
    id: params.callId,
    nameCaller: params.callerName.isNotEmpty
        ? params.callerName
        : params.callerNumber,
    handle: params.callerNumber,
    type: params.isVideo ? 1 : 0,
    duration: params.ringTimeoutSecs * 1000,
    extra: params.toMap(), // ← CRITICAL: stores ws_url + call_token
    ios: const IOSParams(
      supportsVideo: false,
      maximumCallGroups: 1,
      maximumCallsPerCallGroup: 1,
      audioSessionMode: 'voiceChat',
      audioSessionActive: true,
      audioSessionPreferredSampleRate: 44100.0,
      audioSessionPreferredIOBufferDuration: 0.005,
    ),
    android: const AndroidParams(
      isShowFullLockedScreen: true,
    ),
    notification: const NotificationParams(
      showNotification: true,
      isShowMissedCallNotification: true, // Automatically triggers missed call alert on timeout
    ),
  ));
}
```

:::caution
The `extra: params.toMap()` is **critical**. It stores the push payload (including `ws_url` and `call_token`) inside the `CallKitParams` so the answer handler can retrieve them later.
:::

### 5. Handle Answer / Decline (Cold Start)

Register a **global** CallKit event listener in `main()` **before** `runApp()`:

```dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_backgroundHandler);

  // Global listener — captures answer/decline even on cold start
  FlutterCallkitIncoming.onEvent.listen((CallEvent? event) async {
    if (event == null) return;
    switch (event.event) {
      case Event.actionCallAccept:
        await _handleAnswer(event);
      case Event.actionCallDecline:
        await _handleDecline(event);
      default:
        break;
    }
  });

  runApp(const MyApp());
}
```

#### Answering

```dart
Future<void> _handleAnswer(CallEvent event) async {
  final body = event.body as Map<String, dynamic>?;
  final extra = body?['extra'] as Map<String, dynamic>?;
  if (extra == null) return;

  final params = CallRingParams.fromMap(extra);
  final iceServers = await IceServerCache.load(); // cached workspace TURN servers

  final call = Call(
    iceServers: iceServers,
    options: CallOptions(
      to: params.calleeNumber,
      from: params.callerNumber,
      fromName: params.callerName,
    ),
  );
  call.callId = params.callId;

  // Connect WS + accept — uses ws_url + call_token from push payload
  await call.connectSignaling(params.wsUrl!, params.callToken);
  await call.accept();
  // Audio flows ✅ — navigate to call screen
}
```

#### Declining

```dart
Future<void> _handleDecline(CallEvent event) async {
  final body = event.body as Map<String, dynamic>?;
  final extra = body?['extra'] as Map<String, dynamic>?;
  if (extra == null) return;

  final callId = body?['id']?.toString();
  final callToken = extra['call_token'] as String?;
  final wsUrl = extra['ws_url'] as String?;

  if (callId != null && callToken != null && wsUrl != null) {
    // Convert wss://host/ws → https://host
    final uri = Uri.parse(wsUrl);
    final baseUrl = '${uri.scheme == 'wss' ? 'https' : 'http'}://${uri.host}';

    final call = Call(iceServers: []);
    call.callId = callId;
    await call.rejectViaHttp(baseUrl: baseUrl, callToken: callToken);
    // Rejected in ~50ms ✅
  }
}
```

---

## Push Payload Format

### `call.ring`

```json
{
  "event": "call.ring",
  "call_id": "cl_abc123",
  "call_token": "eyJhbGci...",
  "ws_url": "wss://ws_abc.firetell.app/ws",
  "from": {
    "number": "+84901234567",
    "name": "Nguyen Van A",
    "avatar": "https://..."
  },
  "to": {
    "number": "1001",
    "name": "Agent Smith"
  },
  "is_video": false,
  "is_transfer": false
}
```

### `call.canceled` / `call.ended`

```json
{
  "event": "call.canceled",
  "call_id": "cl_abc123"
}
```

---

## ICE Server Caching

The SDK caches workspace ICE servers (including TURN credentials) to local storage. On cold start, push-originated calls use the cached servers for better connectivity.

```
First login:
  FiretellClient → fetch metadata → ICE servers (with TURN)
  → IceServerCache.save() → SharedPreferences ✅

Cold start push answer:
  IceServerCache.load() → cached TURN servers ✅
  → Call(iceServers: cachedServers) → connect → accept

Never logged in (no cache):
  IceServerCache.load() → default STUN servers (Google, Cloudflare)
```

---

## Logout — Unregister Tokens

```dart
await PushTokenService.logout(
  baseUrl: client.baseUrl,
  jwt: client.jwt,
  deviceId: await DeviceIdHelper.getOrCreate(),
);
```

This removes the device's push tokens server-side so it no longer receives incoming call pushes.
