こんにちは、株式会社RevCommでAndroidアプリ開発を担当している吉村です。
私が開発を担当しているアプリに MiiTel Phone Mobileというものがあります。このアプリはスマートフォンでインターネット回線を介して発着信ができる通話アプリです。日々の業務において機能追加・機能改修をする場面は多いのですが、その中でも通話部分の実装には通話アプリ特有の実装の難しさがあります。
今回は、その一例として、通話やビデオチャットを実装するときに用いられるテレコムフレームワークの導入方法について、実装例を交えて紹介していきます。
目次
テレコムフレームワークとは
テレコムフレームワークとは一言で言うと「Android OSに通話状態を伝えることにより、OSを介して他の通話アプリと通話状態を共有するための機能」です。
とはいえこの一言では、なぜテレコムフレームワークを用いて他の通話アプリと通話状態を共有する必要があるのか分からないと思います。したがって、「他の通話アプリと通話状態が共有されていないとどういった不具合が発生するか」を考えてみましょう。
なぜ通話状態を共有する必要があるのか
結論としては「通話を排他制御するため」です。
仮に、端末に通話状態をOSに共有する機能がない通話アプリAと、通常の通話アプリBがそれぞれインストールされているとします。そして、アプリAで通話中にアプリBに向けて電話がかかってきたら、アプリBはアプリAが通話中であることを知らないので「保留」という選択肢は出てきません。「電話に出る」か「着信を拒否する」の二択になります。電話に出てしまった場合、一対二で通話が成立してしまうこともあり得ます。
アプリで通話中に他の通話アプリに着信が発生したら「保留して電話に出る」「着信を拒否する」などの選択肢が表示されるのが一般的な電話のイメージかと思います。電話として当たり前の機能のようですが、通話アプリがOSを介して「他の通話アプリで既に通話中である」と知っていて初めてこれらの選択肢を出すことができます。
このように、アプリ間で通話の状態を共有することは、通話を排他制御するために重要となってきます。Androidにおいては、テレコムフレームワークを利用することで、他の通話アプリの通話状態を取得することができます。
テレコムフレームワークの導入手順
それでは、他の通話アプリと通話状態を共有するために、テレコムフレームワークを通話アプリに実装してみましょう。
公式ドキュメントによると、テレコムフレームワークを通話アプリに組み込むオプションとして
- self-managed型
- managed型
の二つが存在していますが、本稿は、self-managed型のオプションを選択した場合について説明しています。
なお、self-managed型を選択した場合、着信時・発信時のUIなども含めて全て自前で実装することが可能です(MiiTel Phone Mobileにおいては発着信時のUIを自前で実装しているため、こちらを採用しています)
managed型を選択した場合、Androidデフォルトの着信時・発信時のUIを利用することで、self-managed型と比較して、簡便に実装することが可能です。
まずはテレコムフレームワークの導入の流れを簡単に説明します。
※公式提供のライブラリのメソッド名やクラス名は斜体表記とします。
- AndroidManifestにPermissionとConnectionServiceの指定
- テレコムフレームワークを扱うために必要なPermissionを追加し、通話開始時に起動するServiceクラスを指定します。
- ConnectionServiceの実装
- ConnectionServiceとは、発信時 or 着信時に起動するServiceクラスです。発着信時に発信中または着信中の状態を持ったConnectionのインスタンスを生成し、それをアプリケーションのスコープで保持するようにします。
- Connectionインスタンスの通話状態の監視の実装
- OSと通話状態を共有するためのConnectionクラスを実装し、その通話状態の変更を監視するlistenerを実装します。
- Connectionの状態を変更・監視するための機能の実装
- アプリ側からテレコムフレームワークを使用するためのinterfaceを定義し、実装例を解説します。
- Connectionの状態(通話状態)の監視
- Connectionのstateを監視するlistenerの実装例を解説します。
上記のような流れとなります。それでは詳細に入っていきます。
AndroidManifestにPermissionとConnectionServiceを指定
OSから通話状態を取得したり、通話状態を変更するための権限が必要なので、下記の三つのパーミッションを追加します。
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
また、通話を開始するときに立ち上げるConnectionServiceというサービスクラスもAndroidManifestにて指定する必要があります。(ConnectionServiceの実装については後述)
<service android:name="jp.co.sample.telecom.MyConnectionService" android:exported="false" android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"> <intent-filter> <action android:name="android.telecom.ConnectionService" /> </intent-filter> </service>
ConnectionServiceの実装
前節でAndroidManifestにてConnectionServiceの実装クラスを指定しました。
ConnectionServiceとはざっくり言うと「通話開始」をOSに伝えるためのServiceクラスです。
発信するときにTelecomManager.placeCall()をコールするとConnectionServiceのonCreateOutgoingConnection()が呼ばれます(着信を受ける場合はTelecomManager.addNewIncomingCall()をコールするとonCreateIncomingConnection()が呼ばれます)。
onCreateOutgoingConnection()内でConnectionという通話状態を伝えるためのインスタンスを生成して、それを返すとOSに通話開始を知らせることができます。
ConnectionServiceを継承したクラスを作成し、発信時にTelecomManager.placeCall()を呼ぶことで呼び出されるメソッドonCreateOutgoingConnection()を実装します。
// class MyConnectionService : ConnectionService() override fun onCreateOutgoingConnection( connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest? ): Connection { // TelecomManager.placeCall()を呼ぶとこのメソッドが呼ばれます // ↓また、placeCall()の引数に渡したbundleから値を受け取れます val name = request?.extras?.getString("name") val connection = MyConnection(stateChangedListeners).apply { // 発信者名をセットしています setCallerDisplayName(name, TelecomManager.PRESENTATION_ALLOWED) // Connectionのstateをdialingに設定しています setDialing() } return connection }
次に、上記の発信時とほぼ同様の着信時のコードを追加します。
// class MyConnectionService : ConnectionService() override fun onCreateIncomingConnection( connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest? ): Connection { val bundle = request?.extras?.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS) val name = bundle?.getString("name") val connection = MyConnection(stateChangedListeners).apply { setCallerDisplayName(name, TelecomManager.PRESENTATION_ALLOWED) setRinging() } return connection }
そして上記の発着信をしたときに生成したConnectionに渡すlistenerの管理をするメソッドを追加します。
// class MyConnectionService companion object { // リスナーが複数セットされるため private val stateChangedListeners = mutableListOf<ConnectionStateChangedListener>() fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { stateChangedListeners.add(listener) } fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { stateChangedListeners.remove(listener) } }
Connectionの実装
次にConnectionを実装します。ConnectionはOSとアプリを繋ぐ重要な役割を持ったクラスです。
Connectionを実装するにあたってすべきことは二つあって、一つ目はstateの変化を取得することです。例えばstateがIncomingになったら着信画面を表示したり、stateがOutgoingになったら発信画面を表示したり、stateに応じて行いたいことがあるかと思います。そのためにstateの変化を取得します。
二つ目は、OSに由来したポップアップに対してユーザーがアクションしたときに、OSからそのアクションを受け取ることです。ConnectionのonAnswer(), onDisconnect()などが呼ばれるので、その結果に応じてConnectionのstateを変更します。
これらを実装することによって、OSにこのアプリの通話状態が伝わったかどうかという情報や、他アプリの通話状態からどのような影響を受けたか(例えば他アプリで着信を受けたから受電するために、このアプリの通話は切電しようとした、などのアクション)、などの情報を得られます。
具体的な実装に移ります。
まずはConnectionを継承し、初期化処理を書きます。通話状態を監視するためのlistenerのリストをコンストラクタに渡しています。(複数箇所で監視する必要がある場合のためにlistenerを複数セットできるようにリスト形式にしています)
interface ConnectionStateChangedListener { fun onStateChanged(state: Int, connection: MyConnection) } class MyConnection( private val stateChangedListeners: MutableList<ConnectionStateChangedListener> = mutableListOf() ) : Connection() { init { audioModeIsVoip = true // 自己管理型の通話アプリの場合は必要です // (これを設定しないと発信時にデフォルトの発信画面が表示されます) connectionProperties = PROPERTY_SELF_MANAGED setInitializing() }
通話状態の変更があった場合にlistenerに伝えます。
// class MyConnection override fun onStateChanged(state: Int) { super.onStateChanged(state) stateChangedListeners.map { listener -> listener.onStateChanged(state, this) } }
また、他のアプリとの通話状態の兼ね合いで、OS由来のポップアップが出てくることがあり、そのポップアップへのアクション結果が下記の三つのメソッドにコールバックされます。
まずはonDisconnect()
についてですが、一例として、このアプリで通話中に他のアプリから発信しようとすると「この通話を発信すると、このアプリの通話が終了します。」という旨のポップアップが表示され、OKをタップするとonDisconnect()
がコールされます。下記にサンプルとしてPixel5(Android 11)のポップアップを載せますが、OSや機種によって若干の文言の違いがあったり、挙動が異なる場合があります。
※「TelecomFrameworkSam...」という表示は、アプリ名であるTelecomFrameworkSampleが略されて表示されたものです。
次に、onAnswer()
とonReject()
についてです。一例として、このアプリで着信中に他のアプリから着信があると「応答すると、進行中の通話は終了します」というポップアップが出て、「応答」か「拒否」かをタップすると上記メソッドがコールされます。下記がサンプルとなります。
※「test incoming user」というのはConnectionに設定したもので、「Telecom..」というのはアプリ名であるTelecomFrameworkSampleの略となります。
// class MyConnection override fun onAnswer() { super.onAnswer() // OS由来のポップアップに対して電話に出るという類のアクションをするとコールされます setActive() } override fun onReject() { super.onReject() // OS由来のポップアップに対して拒否するという類のアクションをするとコールされます setDisconnected(DisconnectCause(DisconnectCause.REJECTED)) } override fun onDisconnect() { super.onDisconnect() // OS由来のポップアップに対して切電するという類のアクションをするとコールされます setDisconnected(DisconnectCause(DisconnectCause.UNKNOWN)) }
Connectionの状態を変更・監視する機能の実装
前節においてConnectionの状態を取得するところまで実装できました。
ただ、上述の一通りの実装を読んでみても「結局のところどのクラスをどうやって使えば良いの?」としっくりこないかと思います。より簡潔にテレコムフレームワークを扱えるようにしたいです。
冒頭の説明を繰り返すと、そもそもテレコムフレームワーク本来の目的は「他の通話アプリと通話状態を共有すること」でしたよね。
では、そのために必要なことは何なのか。
- 発信をOSに伝えること
- 着信をOSに伝えること
- 通話開始をOSに伝えること
- 保留中をOSに伝えること
- 通話終了をOSに伝えること
- Connectionの状態(通話状態)を監視すること
上記を満たせば、OSに十分に通話状態が伝わるかと思います。上記の箇条書きをinterfaceにしてみます。
interface TelecomHelper { @RequiresPermission(Manifest.permission.READ_PHONE_STATE) // 発着信の際に必要なため fun initPhoneAccount(): PhoneAccount? @RequiresPermission(Manifest.permission.CALL_PHONE) fun startOutgoing(number: String, name: String, accountHandle: PhoneAccountHandle) fun startIncoming(name: String, accountHandle: PhoneAccountHandle) fun activate() fun hold() fun disconnect() fun firstConnectionOrNull(): MyConnection? fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) }
ほとんど箇条書きそのままのinterfaceを定義できました。このinterfaceを実装して必要に応じて呼び出すことができれば、アプリのソースコードがすごくクリーンに保たれるかと思います。モジュール化しても良いと思います。
続いて、interfaceの実装サンプルです。やや実装量が多いのでメソッドごとに分解して紹介していきます。
まずクラスの構造については、TelecomHelperを継承してコンストラクタにContextとTelecomManagerをインジェクトし、Connectionを保持するリストをメンバに置きます。
class TelecomHelperImpl @Inject constructor( @ApplicationContext private val context: Context, private val telecomManager: TelecomManager ) : TelecomHelper { private val connections = mutableListOf<MyConnection>() }
続いて各メソッドについてです。
まずは発着信時に、ConnectionServiceにおいて生成されたConnectionをリストに追加したり、stateがdisconnectedになった時にリストから除外するためのlistenerをセットします。
// class TelecomHelperImpl init { // Connectionのaddやremoveをするためにリスナーをセット val listener = object : ConnectionStateChangedListener { override fun onStateChanged( state: Int, connection: MyConnection ) { when (state) { Connection.STATE_RINGING -> { startConnection(connection) } Connection.STATE_DIALING -> { startConnection(connection) } Connection.STATE_DISCONNECTED -> { endConnection(connection) } } } } MyConnectionService.addConnectionStateChangedListener(listener) }
listenerをadd or removeするためのメソッドを追加します。
// class TelecomHelperImpl override fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.addConnectionStateChangedListener(listener) } override fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.removeConnectionStateChangedListener(listener) }
Connectionをメンバのリストにadd or removeするためのメソッドです。
endConnection()においてはConnectionの破棄も行っています。
// class TelecomHelperImpl private fun startConnection(connection: MyConnection) { connections.add(connection) } private fun endConnection(connection: MyConnection) { connections.remove(connection) connection.setDisconnected(DisconnectCause(DisconnectCause.UNKNOWN)) connection.destroy() }
次に、PhoneAccountを取得するためのメソッドたちを実装します。PhoneAccountとは、通話時の通信プロトコルの指定やself-managed形式の指定などをするためのクラスです。常に新規にアカウントを作ることはせず、ConnectionServiceにPhoneAccountが既に紐付いていた場合はそれを取得します。
// class TelecomHelperImpl @RequiresPermission(Manifest.permission.READ_PHONE_STATE) override fun initPhoneAccount(): PhoneAccount { return findExistingAccount(context) ?: return createAccount(context) } private fun createAccount(context: Context): PhoneAccount { val accountHandle = PhoneAccountHandle( ComponentName(context, MyConnectionService::class.java), context.packageName ) val account = PhoneAccount.builder(accountHandle, "test") .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) .setSupportedUriSchemes(listOf(PhoneAccount.SCHEME_SIP)) .build() telecomManager.registerPhoneAccount(account) return account } @RequiresPermission(Manifest.permission.READ_PHONE_STATE) private fun findExistingAccount(context: Context): PhoneAccount? { val connectionService = ComponentName( context, MyConnectionService::class.java ) val targetPhoneAccountHandle = telecomManager.selfManagedPhoneAccounts.firstOrNull { phoneAccountHandle -> phoneAccountHandle.componentName == connectionService } return telecomManager.getPhoneAccount(targetPhoneAccountHandle) }
続いて、発信を開始するときにstartOutgoing()をコールします。その内部でTelecomManager.placeCall()を呼びます。これによりConnectionServiceが立ち上がってConnectionが追加されます。
// class TelecomHelperImpl @RequiresPermission(Manifest.permission.CALL_PHONE) override fun startOutgoing(number: String, name: String, accountHandle: PhoneAccountHandle) { telecomManager.placeCall( Uri.fromParts("tel", number, null), Bundle().apply { putParcelable( TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, Bundle().apply { putString("name", name) } ) putParcelable( TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accountHandle, ) } ) }
そして着信時にstartIncoming()をコールします。その内部でTelecomManager.addNewIncomingCall()を呼ぶことでConnectionServiceが立ち上がって、Connectionが追加されます。
// class TelecomHelperImpl override fun startIncoming(name: String, accountHandle: PhoneAccountHandle) { telecomManager.addNewIncomingCall( accountHandle, Bundle().apply { putParcelable( TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, Bundle().apply { putString("name", name) } ) } ) }
そして最後に、通話状態が変更された際に、そのことをアプリからOSに伝えるためにConnectionインスタンスのstateを変更します。activete()メソッドは、通話開始時および保留解除時、hold()メソッドは、保留開始時、disconnect()メソッドは切電時にコールするようにします。
実装例は以下の通りです。
// class TelecomHelperImpl override fun activate() { connections.lastOrNull { it.state == Connection.STATE_DIALING || it.state == Connection.STATE_RINGING || it.state == Connection.STATE_HOLDING }?.setActive() } override fun hold() { connections.lastOrNull { it.state == Connection.STATE_ACTIVE }?.setOnHold() } override fun disconnect() { connections.lastOrNull { it.state == Connection.STATE_DIALING || it.state == Connection.STATE_RINGING || it.state == Connection.STATE_ACTIVE || it.state == Connection.STATE_HOLDING }?.let { endConnection(it) } }
以上がTelecomHelperの実装例となります。
Connectionの状態(通話状態)の監視
前節の序盤で、Connectionのstateを監視するためのメソッドを追加しました。
// class TelecomHelperImpl override fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.addConnectionStateChangedListener(listener) } override fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.removeConnectionStateChangedListener(listener) }
上記の二つのメソッドです。
本節では、このlistenerのセット方法と、監視方法についての実装サンプルをご紹介します。
// class SomeViewModel: ViewModel() private val listener = object : ConnectionStateChangedListener { override fun onStateChanged( state: Int, connection: MyConnection ) { STATE_INITIALIZING -> {} STATE_NEW -> {} STATE_RINGING -> {} STATE_DIALING -> {} STATE_ACTIVE -> {} STATE_HOLDING -> {} STATE_DISCONNECTED -> {} STATE_PULLING_CALL -> {} } } init { telecomHelper.addConnectionStateChangedListener(listener) } override fun onCleared() { // メモリリークしないように telecomHelper.removeConnectionStateChangedListener(listener) super.onCleared() }
上記でConnectionのstateの変化を監視できます(メモリリークしないよう、ライフサイクルに応じて適切にlistenerを取り外してください)。
これによって、例えば発信がスタートしたときにSTATE_DIALINGの部分を通るので発信画面を出したり、着信ならSTATE_RINGINGを通るので着信画面を出したり、STATE_DISCONNECTEDなら通話中画面を閉じたりといった実装ができます。
以上で実装方法については終わります。Helperクラスを介して、アプリとOS間で通話状態の共有ができました。
終わりに
テレコムフレームワークの導入に関する情報はニッチすぎてなかなか見つからないので、今後実装に取り組まれる方にとってこの記事が少しでもお役に立てれば幸いです。
また、RevComm ではエンジニアを募集しています。技術好きな方々が多く在籍しており、モブプロや勉強会などが盛んに行われています。ぜひぜひ奮ってご応募ください。
参考
- 公式ドキュメント: Telecom フレームワークの概要
- linphone-android