実装難易度Sランク:YOUTRUSTの1対1音声通話を作った

こんにちは👋 三ヶ月で様々な山を超えた
アプリチームのルーカス (YOUTRUST / X) です

最近、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で統合しないと破綻します。

コールフロー:発信〜接続まで(アウトゴーイング)

まずは発信側の流れ。 ユーザーがチャット画面で電話ボタンを押すところからです。

  1. UIタップ → start()
    1. ChatCommentScreen で通話ボタンタップ
    2. ChatCommentViewModel → PhoneCallFacade.start(chatRoomId)

ここでFacadeがやることは2つ:

  • Backendに「発信開始」を宣言
  • AgoraのJoin準備を整える

着信フロー:iOS(PushKit + CallKit)

iOSは、ここが一番 “OSっぽい” 体験になります。

  1. VoIP Pushで着信

BackendがAPNsに VoIP Push を投げる。 AppDelegate.swift が PKPushRegistry で受け取ります。

Payloadの type が "create_call" のとき、_handleIncomingCall() -> SwiftFlutterCallkitIncomingPlugin.showCallkitIncoming()

で CallKitの着信画面を出します。

  1. ユーザーが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 に遷移

ここでようやく通話開始です。

  1. BackendでCallを作ってもらう

POST /api/chat_rooms/{chatRoomId}/calls

返ってくるのが重要で、

  • Call(callId, participants, status etc)
  • RtcCredentials(channel name, token, uid)

つまりここで AgoraにJoinするための鍵を受け取ります。

  1. Agora Engine 初期化 → Join

  2. PhoneCallManager.initialize() でAgora Engineを生成

  3. PhoneCallManager.join(credentials) でチャネル参加

このタイミングで “通話の土台” ができる。

  1. ネイティブ通話UIを起動

FlutterCallkitIncoming.startCall()

これにより

  • iOS: CallKitの発信UI
  • Android: 通話通知UI(発信状態)

を出します。

  1. リングバックトーン開始

発信側は “プルルル…” を鳴らしたいので、PhoneCallRingtoneFacade が実はagoraで再生。 最初に、just_audioを試してみましたけど、iOSとのAudiosessionと問題が発生しましたので、agoraの再生 機能を直接使いました。

そして、ここがキモ:

相手が応答してAgoraに入ってきた瞬間(onUserJoined)で止める

Agoraのイベントが “電話の状態遷移” のトリガーになります。

onUserJoined → リング停止 → タイマー開始

着信フロー:Android(FCM + SharedPreferences)

AndroidはiOSほどCallKitが強くないので、作り方が変わります。

  1. FCM high priority で着信

MessagingService.kt が "create_call" を受けて、Callkit通知UIを出します。

  1. 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]