最近、YOUTRUSTアプリに「電話」ボタンがあるのに気づいた方もいるかもしれません。

そう、ついにチャットの延長でそのまま1対1の音声通話ができる機能を入れました。📞🔥
そして…この仕掛け人は誰でしょうか…? 実は、僕なんです。🤙
結構複雑でした

YOUTRUSTの電話機能を作った話 〜Flutter×ネイティブ×Agora×CallKitの総力戦〜
YOUTRUSTは、メッセージが主役です。
- でも転職キャリアの文脈って、文章だけじゃ解決しない瞬間がある。
- ちょっと温度感を揃えたい
- 誤解を早く解きたい
- “今ここで” 10分だけ話したい
こういう瞬間に、「チャットの中でそのまま電話できる」って、体験として強いんですよね。
ただ、実装は…めちゃくちゃ奥が深いです。 「通話できるようにする」だけならまだしも、iOS/AndroidそれぞれのOS制約・Push・ネイティブUI・音声ルーティングまで全部揃えないと “電話っぽい体験” にならない。
今回は、その全体像を “実装者の視点” で、アーキテクチャから泥臭い落とし穴までまとめます。
まず結論:今回のスコープ
今回入れたのはこれです👇
- ✅ 1対1の音声通話(発信 / 着信 / 終了 / 拒否)
- ✅ iOSは CallKit で “本物の電話UI”
- ✅ Androidは 通知ベースの着信UI(FlutterCallkitIncoming)
- ✅ チャット履歴に通話イベントを表示(受発信履歴が残る)
- ✅ Bluetooth/スピーカー/イヤホンの切替
逆にやらないこと👇
- ❌ ビデオ
- ❌ グループ通話
- ❌ 録音
採用技術スタック(ここが戦場)
今回の主要メンバーはこんな感じです。
| 技術 | 役割 |
|---|---|
| Agora RTC Engine | リアルタイム音声ストリーミング(VoIPの本体) |
| FlutterCallkitIncoming | iOSのCallKit UI / Androidの通知ベース着信UI |
| Firebase Cloud Messaging (FCM) | Androidの着信Push(高優先度)・通話終了メッセージ配信 |
| PushKit(VoIP Push) | iOSの着信Push・通話終了メッセージ配信(PushKit経由) |
| Pigeon | 音声デバイスルーティング等のDart↔Native通信を型安全にする |
| Riverpod | 状態管理と依存性注入(Store/Providerで通話状態を管理) |
| Freezed | 不変モデル生成 + JSONシリアライズ(Call, RtcCredentials等) |
ポイントはこれです:
通話 = “リアルタイム音声(Agora)” + “OSの電話体験(CallKit/通知)” + “Push/Background” + “音声ルーティング” この4つが揃って初めて “通話” になります。
アーキテクチャ:YOUTRUST標準のレイヤーに落とし込む
YOUTRUSTアプリの基本構造はこうです:
Screen → ViewModel → Facade → Manager → Store
通話もこの型に沿って実装しました。
Screen (PhoneCallScreen, ChatCommentScreen, TabsScreen) ↓ ViewModel (PhoneCallViewModel, ChatCommentViewModel, TabsViewModel) ↓ Facade (PhoneCallFacade, ...Ringtone, ...Socket, AudioDevices, VoipToken) ↓ Manager (PhoneCallManagerImpl wrapping Agora) ↓ Store (CurrentCallStore etc. - Riverpod) ↓ External (Agora, CallKit, FCM/VoIP Push)
この構造が効いた理由はシンプルで、
- SDK依存(Agora/CallKit)はManagerに隔離
- ビジネスフローはFacadeで一本化
- UIはScreenに置かず、状態はStoreに寄せる
「通話」は複数の入力源(UIタップ / Push / ネイティブイベント / Agoraコールバック)があるので、 入口が増えるほど、Facadeで統合しないと破綻します。
コールフロー:発信〜接続まで(アウトゴーイング)
まずは発信側の流れ。 ユーザーがチャット画面で電話ボタンを押すところからです。

- UIタップ → start()
- ChatCommentScreen で通話ボタンタップ
- ChatCommentViewModel → PhoneCallFacade.start(chatRoomId)
ここでFacadeがやることは2つ:
- Backendに「発信開始」を宣言
- AgoraのJoin準備を整える

着信フロー:iOS(PushKit + CallKit)
iOSは、ここが一番 “OSっぽい” 体験になります。
- VoIP Pushで着信
BackendがAPNsに VoIP Push を投げる。 AppDelegate.swift が PKPushRegistry で受け取ります。
Payloadの type が "create_call" のとき、_handleIncomingCall() -> SwiftFlutterCallkitIncomingPlugin.showCallkitIncoming()
で CallKitの着信画面を出します。
- ユーザーがCallKitでAccept
ここからFlutter側にイベントが飛びます。
- FlutterCallkitIncoming.onEvent で accept が流れてくる
- TabsViewModel が捕まえて
PhoneCallFacade.acceptActive() を呼ぶ
activeCalls() から通話情報を取り出す
iOSはアプリ側で保持しなくても、CallKit側に「今鳴っている通話情報」があるので
- FlutterCallkitIncoming.activeCalls()
から取り出して
- PUT /api/chat_rooms/{chatRoomId}/calls/{callId}/accept
- RtcCredentials を取得
- Agora Join
- PhoneCallScreen に遷移
ここでようやく通話開始です。
- BackendでCallを作ってもらう
POST /api/chat_rooms/{chatRoomId}/calls
返ってくるのが重要で、
- Call(callId, participants, status etc)
- RtcCredentials(channel name, token, uid)
つまりここで AgoraにJoinするための鍵を受け取ります。
Agora Engine 初期化 → Join
PhoneCallManager.initialize() でAgora Engineを生成
- PhoneCallManager.join(credentials) でチャネル参加
このタイミングで “通話の土台” ができる。
- ネイティブ通話UIを起動
FlutterCallkitIncoming.startCall()
これにより
- iOS: CallKitの発信UI
- Android: 通話通知UI(発信状態)
を出します。
- リングバックトーン開始
発信側は “プルルル…” を鳴らしたいので、PhoneCallRingtoneFacade が実はagoraで再生。 最初に、just_audioを試してみましたけど、iOSとのAudiosessionと問題が発生しましたので、agoraの再生 機能を直接使いました。
そして、ここがキモ:
相手が応答してAgoraに入ってきた瞬間(onUserJoined)で止める
Agoraのイベントが “電話の状態遷移” のトリガーになります。
onUserJoined → リング停止 → タイマー開始
着信フロー:Android(FCM + SharedPreferences)
AndroidはiOSほどCallKitが強くないので、作り方が変わります。
- FCM high priority で着信
MessagingService.kt が "create_call" を受けて、Callkit通知UIを出します。
- Acceptした時点で「アプリが起動してない」問題
ここがAndroidの一番イヤなところで、 通知でAcceptされた瞬間に Flutter が生きている保証がない。
だから、Acceptイベントは MainActivity.kt で受けて、
- call data を SharedPreferences に保存
アプリ起動するときに、そこから読み込めます
アプリ起動後に acceptActive() で回収
Flutter側が立ち上がったタイミングで
- PhoneCallFacade.acceptActive()
- _acceptIncomingPhoneCallAndroid() が SharedPreferences を読む
- accept API → credentials取得 → Agora join → 画面遷移
という “後追い方式” にしています。
終了フロー:終了は “入口が多い”
通話の終了は発生源が多いので、ここもFacadeで統合しました。
終了パターンはざっくり4つ:
1) 自分がEndボタンを押す
- PhoneCallFacade.end() → PUT .../end
- Agora leave/dispose
- CallKit UIを閉じる
- Storeクリア
2) 相手が落ちる(ネット落ち/アプリ落ち)
- Agora onUserOffline → endCallWhenUserLeft()
3) ネイティブUIで終了(CallKitの終了ボタン)
- FlutterCallkitIncoming.onEvent の end を拾って同様にcleanup
4) “相手側が終了した” をPushで受ける
- Android: FCM "end_call"
- iOS: VoIP Push "end_call"
ネイティブで受け取ったら MethodChannel "youtrust/phone_call" 経由でFlutterへ endCall を投げて、 Facadeが最終的に片付けます。
重要なのは、どこから来ても “同じcleanup” に流すこと。 これをやらないと、状態の二重管理で地獄になります。
音声ルーティング:地味に一番ユーザーが気づく
通話体験で “雑” が一番バレるのがここです。
- スピーカーになるべき?
- イヤホン優先?
- Bluetooth繋いだら切り替える?
- CallKit割り込み後に戻る?
今回の方針はこれ👇
デフォルトは Bluetooth → イヤホン(耳) → スピーカーは最後 (電話なので、勝手にスピーカーはやめたい)
さらに、Flutterだけでは厳しいので、Pigeonで型安全なNativeブリッジを作りました。
- iOS: AVAudioSession(mode: .voiceChat)
- CallKitがAudioSessionを割り込んでくるので、復帰時に再適用するワークアラウンドも入れてます
- Android: AudioManager(MODE_IN_COMMUNICATION)
- BluetoothはSCOで扱う
- 両方:デバイス接続/切断のリアルタイム検出 → 自動スイッチ
この辺、実装者だけが知ってるんですが、
“Bluetoothボタンがある” だけじゃ足りなくて “勝手に正しい方に寄せてくれる” のが電話っぽさ
なんですよね。
iOS/Androidの差分:同じ機能でも実装は別世界
最後に、現場の実感として大事だったこと。
| 項目 | Android | iOS |
|---|---|---|
| 着信Push | FCM | VoIP Push |
| 着信UI | 通知 | CallKit |
| 受諾データ | SharedPreferencesで保存 | activeCalls() で取得 |
| 終了通知 | FCM | VoIP Push |
| 音声API | AudioManager | AVAudioSession |
| Bluetooth | SCO | HFP |
| VoIP token | 使わない(削除運用) | resume ごとに送信 |
「同じ通話機能」だけど、 設計思想そのものがOSごとに違うので、抽象化の粒度が重要でした。
だからこそ、
- SDK操作はManagerへ
- 入り口統合はFacadeへ
- 状態はStoreへ
という構造が効いたと思ってます。
正直、開発中いちばんしんどかったところ(振り返り)
今回の通話機能、技術的に面白い反面、メンタル的には普通に削られました。 「動いた!」の瞬間は最高なんだけど、そこに行くまでが長い。しかもハマりどころが FlutterというよりOSと音声。
ここからは、僕が開発中に「これ人生で一番キツいかも」と思ったポイントを、供養として書きます。
1) 音声デバイス選択:AndroidとiOSは“優先順位”が別の生き物
スピーカー / イヤホン / Bluetooth。 ユーザーからすると「ボタンで切り替えるだけ」なんですが、実際は OSごとに“正しい挙動”の定義が違うのが地獄でした。
- Androidは AudioManager を中心に、Bluetoothは SCO、通話モードは MODE_IN_COMMUNICATION
- iOSは AVAudioSession の世界で、Bluetoothは HFP、mode: .voiceChat
さらに厄介なのが、Bluetooth絡みの優先順位。 - 「Bluetoothが接続されたら自動でそっちに寄せたい」 - でも「勝手にスピーカーになるのは嫌」 - さらに「接続/切断イベントが来たときの切替がOSで微妙に違う」
結果として、UIの選択状態と実際のルーティングがズレる瞬間が発生しうるので、 デバイス変化をリアルタイム監視して、状態を自動再適用する方向に寄せました。
電話体験って、ここが雑だと一発でバレます。 (そしてバレた瞬間、ユーザーは多分二度と使わない。)
2) iOS AudioSession:音を鳴らすだけで事故る(本当に)
iOSの AVAudioSession は、通話をやるときの最大の壁でした。
通話中って、実は “音の種類” が複数あります:
- リングバックトーン(発信側が待ってる時の音)
- 着信音(受信側で鳴る音)
- 通話音声(Agoraでのボイス)
- 終了音(切った時の効果音)
問題は、これらが全部 同じAudioSessionの奪い合いになりがちなこと。
- 通話は .voiceChat で安定させたい
- でも着信音/リング音は “それっぽく” 鳴らしたい
- そしてCallKitが介入すると AudioSession が割り込まれて、復帰時に状態が変わる
開発中、audio_session パッケージと just_audio の組み合わせで 「いけそう!」→「やっぱダメだ…」を何回も繰り返しました。
特にしんどかったのが、
- 通話音声に切り替えたあと、リング音に戻す -リング音を止めたあと、通話音声を復帰する
みたいな「行ったり来たり」。
こういう “状態遷移” は一個でもミスると、
- 無音になる
- スピーカー固定になる
- Bluetoothが切れる
- そもそも鳴らない
みたいな事故が起きます。
最終的には「通話体験を最優先」に割り切って、 CallKit割り込み後に音声ルーティングを再適用するワークアラウンドも含めて、堅めに寄せました。
3) FlutterCallkitIncoming:dynamic地獄 + プラットフォーム差分が強すぎる
FlutterCallkitIncoming、正直めちゃくちゃ助けられた一方で、 開発者体験としてはかなりキツいところがありました。
特にしんどかったのがここ:
- 多くの関数が dynamic を返す
- 型が保証されないので、ランタイムで壊れる可能性が高い
- さらにOSごとの差分が “静かに” 仕込まれている
象徴的なのが getActiveCalls(≒ activeCalls)周りで、
- iOS:List of objects が返ってくる(“今”の通話っぽい)
- Android:objectが1個返ってくるが、しかもそれが “active call” じゃない
→ 最新の通話情報で、場合によっては 数日前の通話が返ることすらある
これ、普通に設計を間違えると、
「Acceptしたのに違う通話を開く」
みたいな事故になりかねない。
だからAndroidでは「activeCallsを信じない」方針にして、 Accept時のデータを SharedPreferencesに保存して後で回収する設計に寄せました。
4) “Decline/End” の扱い:iOSはDartからいけるのに、AndroidはPush経由になる
最後に、地味に精神に来たのがここ。
- iOSは、Dart側からでも比較的素直にCallKit関連の関数が呼べる
- たとえ「Dart側でdeclineした後」でも動いてしまうケースがあって、逆に怖い
- Androidは、アプリが死んでいる/バックグラウンドが絡むと、Dartからはどうにもならないことがある
特に “decline” が厄介で、 - Androidではアプリがterminated状態でも「decline」を反映させたい - そのために FCMのbackground handler で拾って、そこからAPI叩く導線を用意した
つまり、Android側は
「Dartから」じゃなくて 「Firebase Cloud Message を起点に」状態を収束させる必要がある
この差分に気づくまで、かなり時間を溶かしました。
それでもやってよかった
ここまで書くと愚痴みたいに見えるけど、実際は逆で、 こういう “OSの理不尽” を真正面から捌けたのはかなり大きかったです。
通話機能は、Flutterだけの話じゃなくて、 ネイティブ・Push・音声・状態管理の総合格闘技。
だからこそ、完成したときの達成感は過去一でした📞🔥
〆:通話は “機能” というより “総合格闘技”
今回の電話機能は、Flutterだけで完結する話じゃありませんでした。
- Push(FCM/VoIP Push)
- ネイティブUI(CallKit/通知)
- 音声SDK(Agora)
- OSの音声ルーティング
- 状態管理(Riverpod)
- クラッシュ対策
全部が噛み合って、ようやく “電話” になる。
でも、逆に言うと…
ここまで揃えると、チャットの体験が一段上がります📞✨
YOUTRUSTでは、こういう “体験に直結する” 機能を、 Flutter×ネイティブ両方触りながら実装できる環境があります。
もし 「Flutterでプロダクト体験を極めたい」 エンジニアがいたら、 ぜひ一緒に語りましょう!🚀
[https://herp.careers/v1/youtrustinc:embed:cite]
