[Flutter iOS] アプリ未起動時に端末をスリープさせVoIP着信すると受信に失敗する

## 概要

Flutterで[callkeep](https://pub.dev/packages/callkeep "callkeep")およびVoIPを用いたビデオ通話アプリを開発していた中で、「アプリ未起動時のまま端末をスリープにした状態でのVoIP着信が失敗する」という現象に遭遇したのですが、その再現性および対処法が特殊だったので記事にしてみました。

## 前提

本現象確認時の開発環境は以下の通りです。

- Flutter 3.7.3 (Stable)
- iOS 16.3.1 (VoIP受信側)
- callkeep: 0.3.3
- firebase_messaging: 13.1.0

また、ビデオ通話アプリなので通常の電話の発着信のように発信側および受信側の役割があるものとします。

```mermaid
graph LR;
    A("発信側端末 (Android or iOS)")　-->|VoIP| B("受信側端末 (iOS)");
```

### VoIPとは

VoIP (Voice over Internet Protocol) はAPNs (Apple Push Notification service) の一つであり、主に電話発信を行うための機能です。APNsは「通常のプッシュ通知」と、「VoIPプッシュ通知」の2種類に大別されます。前者はごく一般的な通知をユーザーに送るためのもので、クーポンやキャンペーン情報の発信等がこれに当たります。後者は電話発信のために利用し、ビデオ通話アプリ等で特定のユーザーに電話を掛ける際に使用します。

いずれも名前が似ていてややこしいですが、通知の方法によってカテゴリ分けされていると考えたら良いかと思います。ちなみにVoIPは名前にもある通り、インターネット回線を利用して電話の発信を行う点が、従来の固定電話や携帯電話とは異なる特徴です。

## 症状

この度遭遇したバグの再現手順を具体的に表したものが下記です。

1. 受信側端末でアプリをキルする（＝iOSのホーム画面からアプリをスワイプ削除してバックグラウンドでアプリが起動していない状態にする）
1. 受信側端末をスリープモードにする（このとき注意点として、スリープモードになる前にアプリでVoIP着信を受けないようにする。もし受けると手順4でバグが発生しなくなる）
1. 発信側端末は受信側端末に通話発信する
1. バグ発生：受信側端末でVoIP着信が失敗する

手順4で着信が失敗したとき、Macの「コンソール」アプリでデバッグしてみると、次のログが出力されることを確認できます。（コンソールアプリ右上にある検索窓に「voip」と入力することで、大量に出力される無関係なログを非表示にできます）

```
Killing VoIP app <private> because it failed to post an incoming call in time.
VoIP app <private> failed to post incoming call
```

このことから、厳密には「着信に失敗した」というより、「VoIP受信自体はできているがアプリ内部での処理に失敗」して、結果として「着信画面が表示されない」という挙動になっていることが分かるかと思います。

さらに、手順4を3回行う（3回着信に失敗する）と、コンソールアプリにて今度は

```
VoIP app <private> no longer eligible to launch
```

が表示されます。これは受信側端末が当該アプリのバックグラウンドor未起動状態でのVoIP着信をブロックし、以後受信不可となったことを意味します。（ブロックされるのはアプリがバックグラウンドor未起動時でのVoIP受信なので、フォアグラウンド時にVoIP着信するのは引き続き可能）

なお、4回目以降はVoIPを受信する度に下記のログがコンソールアプリに出力されます。

```
VoIP push for app <private> dropped on the floor
```

ブロックを解消するには<b>アプリをアンインストールするか、1日待つ</b>必要があるようです。

当該ブロックに関して、[Apple公式](https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/2875784-pushregistry#discussion "Apple公式")(リンク先の「Important」の箇所)にて言及されているページがあります。

>（抜粋）On iOS 13.0 and later, if you fail to report a call to CallKit, the system will terminate your app. Repeatedly failing to report calls may cause the system to stop delivering any more VoIP push notifications to your app.
→iOS 13以降において、CallKit呼び出しに失敗した場合、システムはアプリを終了させます。繰り返し失敗した場合は、以後アプリへのVoIPプッシュ通知を停止する場合があります。

## 原因

callKeepのバグ？アプリ未起動時にスリープした状態だとVoIP受信のシーケンスが正しく処理されないことが原因か。

## 対処法

VoIP着信の直前に[FCM](https://firebase.google.com/docs/cloud-messaging?hl=ja "FCM")を受信することでバグを回避できました。通常、着信を受ける際は

1. 発信側端末がVoIP通知
1. 受信側端末がVoIP着信

という流れですが、1の前にFCMを用意し、

1. 発信側端末がFCM送信
1. 再び発信側端末がVoIP通知
1. 受信側端末がFCM（プッシュ通知）を受信
1. 受信側端末がVoIP着信

というように、FCM→VoIPの順番で処理を実行するイメージです。

コードで表すと以下のようになります。ただし、なぜFCMを用いると改善されるのかという理由までは分かりませんでした。個人的な推測として、FCM受信（プッシュ通知受信）によりアプリがバックグラウンドで立ち上がることで、VoIPの受信シーケンスが順番通りに作動するためではないかと考えています。

※VoIP通知はNode.js (Firebase Cloud Functions) で実装しています。また、VoIPおよびFCMの基本的な導入、実装部分は省略しています。

```sendVoip.ts
// VoIPを発信する側

// FCMによるプッシュ通知送信
const notification = {
      title: '通話受信中',
      body: '...',
    }
    const apns = {
      headers: {
        "apns-push-type": "alert",
        "apns-collapse-id": "notificationOfCall",
        "apns-priority": "10",
        "apns-topic": "jp.example.app", // アプリのBundle IDに置き換える
      },
      payload: {
        aps: {
          contentAvailable: true,
        },
      },
    }
    const message = {
      token: "your_device_token", // 受信側のデバイストークン
      notification: notification,
      apns: apns,
    }
    admin.messaging().send(message).catch(error => console.error('Error happened to FCM for VoIP:' + error)); // ここでFCM送信

// VoIP通知
const res = {
  uuid: 'your_uuid', // UUID
  isSuccess: false,
  data: null,
};
const client = http2.connect(host, {
      key: fs.readFileSync("./myApp.pem"), // voip certから作ったpem
      cert: fs.readFileSync("./myApp.pem"), // voip certから作ったpem
    });
    client.on("error", (err) => {
      console.error(err);
      reject(err);
    });
    const headers = {
      ":method": "POST",
      "apns-id": "your_uuid", // UUID
      "apns-push-type": "voip",
      "apns-topic": "jp.example.app", // アプリのbundle IDに置き換える
      "apns-expiration": "60",
      "apns-priority": "10",
      ":scheme": "https",
      ":path": "/3/device/" + "your_device_token", // 受信側のデバイストークン
    };
    const body = voipRequest.voipCallData;

    const request = client.request(headers);
    request.on("response", (headers, num) => {
    const status = headers[":status"];
    const apnsId = headers["apns-id"] as string;
    if (status === 200) {
      res.isSuccess = true;
    }
    res.data = {status, apnsId};
    resolve(res);
    });
    let data = "";
    request.setEncoding("utf8");
    request.on("data", (chunk) => data += chunk);
    request.on("end", () => {
      client.close();
    });
    request.write(JSON.stringify(body));
    request.end();
```

```main.dart
// VoIPを受信する側

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  // ...
  FirebaseMessaging.onBackgroundMessage(fireMessageOnBackgroundMessage); // FCM受信
}

Future<void> fireMessageOnBackgroundMessage(RemoteMessage message) async {
  await Eraser.clearAppNotificationsByTag('notificationOfCall');
  // FCM受信後の処理
}

// VoIP受信
FlutterCallkeep
  ..listeners = {}
  ..on(CallKeepDidDisplayIncomingCall(), (CallKeepDidDisplayIncomingCall event) async {
    // ...VoIP受信後の処理
  })
```

ここで工夫として、[eraser](https://pub.dev/packages/eraser "eraser")を使い、VoIP受信直前に受信した不要なプッシュ通知が即座に削除されるようにしています。上記コードの

```sendVoip.ts
"apns-collapse-id": "notificationOfCall",
```

の行で、送信するプッシュ通知に任意のidを付与することで、VoIP受信側の

```main.dart
await Eraser.clearAppNotificationsByTag('notificationOfCall');
```

の行で、当該idを持っているプッシュ通知だけを選択的に削除することができます。

## 参考

https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/2875784-pushregistry#discussion

https://developer.apple.com/forums/thread/128370