Androidで着信通知を実装してみよう

OPTiM TECH BLOG Advent Calendar 2020 12/25 の記事です。


メリークリスマス!

こんにちは、半年ぶり2回目の投稿になります、医療チームの山口です。

最近急に寒くなってきましたね。
2歳半になった子供が11月から「布団蹴飛ばして大の字ぐー → 翌日咳ゴホゴホ&鼻水ジュルル → 病院直行&お薬処方 → 2週間かけてハナタレ治る」を繰り返し、只今3回目のループ中のシロップゲットのところまでいきました。
(もはやクリニックでは顔見知りの常連さんと化しております。。。)

今回はオンライン診療ポケットドクターでも実装している Android の着信通知の実装の解説をしたいと思います。
前回の記事では iOS の着信についてでしたが、今回は Android です。)

着信通知が必要になった背景

オンライン診療ポケットドクターでは、医療機関から発信すると該当の患者の端末にプッシュが届き、着信画面を全面に表示しています。
しかし、Android 10 以降からの OS仕様の制約 *1 により、一部の例外 *2 を除いてバックグラウンドから画面 (Activity) を起動することができなくなりました。
これにより、オンライン診療ポケットドクターでも Android 10 以降ではアプリがバックグラウンドにいる時に、これまで通りの着信画面を全面に出すことができなくなりました。
よって、この代替手段となるものが着信通知になります。

サンプルアプリのコードは GitHub にて公開していますので、そちらも合わせてご確認ください。

着信通知を表示する実装

通常通知との実装を比較したほうが何が違うのかわかりやすいので両方記載します。(Kotlinで書きます。)
GitHub のコード的には こちら

通常通知と着信通知のコード比較

まずは通常通知を表示する方法。
androidx.core.app.NotificationCompat を使うことで、各OS差分をサポートしています。

val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

// Android O 以降はチャンネルを実装する必要がある.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channel = NotificationChannel(ChannelType.NORMAL.id, title, NotificationManager.IMPORTANCE_HIGH)
    manager.createNotificationChannel(channel)
}

val notification = NotificationCompat.Builder(context, ChannelType.NORMAL.id).apply {  // どのチャンネルかを紐づける.
    setSmallIcon(R.drawable.app_icon_small)  // ステータスバーで表示されるアプリアイコン
    setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.app_icon))  // 通知領域内の通知で表示されるアイコン
    setContentTitle(title)  // 通知タイトル
    setContentText(message)  // 通知メッセージ
    setContentIntent(defaultPendingIntent(context, randomId))  // 通知タップ時の挙動. 例の場合はアプリそのものが起動する.
    setPriority(NotificationCompat.PRIORITY_HIGH)  // プライオリティ (Android O 未満対応)
    setDefaults(NotificationCompat.DEFAULT_ALL)  // システムデフォルト (Android O 未満対応)
    setAutoCancel(true)  // タップしたら自動キャンセルする
    setStyle(NotificationCompat.BigTextStyle().bigText(message))  // スタイル(通知領域内の通知で長い文字を表示する.)
}.build()

manager.notify(randomId, notification)


次は着信通知の実装。

val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channel = NotificationChannel(ChannelType.CALL.id, title, NotificationManager.IMPORTANCE_HIGH).apply {
        val attributes = AudioAttributes.Builder().apply {
            setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
            setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
        }.build()
        setSound(ringtoneUri, attributes)  // 1. チャンネルに着信音を設定
    }
    manager.createNotificationChannel(channel)
}

val notification = NotificationCompat.Builder(context, ChannelType.CALL.id).apply {
    setSmallIcon(R.drawable.app_icon_small)
    setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.app_icon))
    setContentTitle(title)
    setContentText(message)
    setPriority(NotificationCompat.PRIORITY_HIGH)
    setDefaults(NotificationCompat.DEFAULT_SOUND)  // 1. システムデフォルトにサウンドを指定 (Android O 未満対応)
    setCategory(NotificationCompat.CATEGORY_CALL)  // 2. カテゴリにCALLを設定
    setAutoCancel(true)
    setStyle(NotificationCompat.BigTextStyle().bigText(message))
    setSound(ringtoneUri, AudioManager.STREAM_RING)  // 1. 通知に着信音を設定 (Android O 未満対応)
    setFullScreenIntent(callPendingIntent(context, NotificationId.CALL.id), true)  // 3. ContentIntent の代わりに FullScreenIntent を設定
    addAction(  // 4. 「拒否」ボタンを追加
        R.drawable.refuse_button,
        getColorString(context, R.string.button_refuse, R.color.colorRefuse),
        refusePendingIntent(context, NotificationId.CALL_REFUSE.id)
    )
    addAction(  // 4. 「応答」ボタンを追加
        R.drawable.accept_button,
        getColorString(context, R.string.button_accept, R.color.colorAccept),
        callPendingIntent(context, NotificationId.CALL_ACCEPT.id, true)
    )
}.build()

// 5. Notificationにフラグを追加
notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_INSISTENT
manager.notify(NotificationId.CALL.id, notification)

具体的に何が違うのか?

変わった点は以下の5つになります。

1. Soundの追加

NotificationChannel に音声リソースを追加することで、チャンネルに紐づく通知が来た時に指定の音声を出すことが可能です。
着信音(プルルル)と通常の通知音(ピコンッ)をわけるために、他チャンネルとは別の着信用のチャンネルIDを決めてチャンネルを独立させましょう。
サンプルコードでは OS に設定された着信音を出すようにしていますが、プロジェクト内のリソースに配置した音声を出す方法もサンプル上にコメントとして記載しています。
なお、NotificationChannel が実装されたのは Android O 以降なので、それ以前のバージョンも対応するために NotificationBuilder にも音声リソースを追加しています。

2. カテゴリの追加

特記事項はないです。単純にカテゴリが着信だと指定しています。

3. PendingIntentの設定方法の変更

通常の通知では、通知タップ時の挙動を ContentIntent にて指定していましたが、着信通知の場合 FullScreenIntent を設定します。
後述の「応答」「拒否」ボタンではなく、通知そのものをタップしたときの挙動がここに設定した PendingIntent になります。
今回の場合は着信通知を実装したいので、全面表示の着信画面 (CallActivity) を指定するようにします。

なお、OSスリープやロック画面からの着信通知を表示は、全画面表示可能な条件(冒頭でお話した一部の例外条件)の一つに含まれます。
この場合は FullScreenIntent に設定した挙動(要するに着信画面の全面表示)が即座に発動します。

4. ボタン (Action) 追加

着信画面なので「応答」「拒否」ボタンが必要なので追加します。
addAction を追加した順番で、ボタンが左から表示されます。

addAction の各引数について少し補足を。
第一引数はアイコンリソースを指定しますが、これは Android M 以前の OS 仕様がアイコンを表示するスタイルだったためであり、Android N 以降でしか使わない場合はダミーアイコンで問題ないです。
第二引数はボタンに表示する文字列なので通常の文字列を渡しても問題ないですが、HTML形式でカラーを指定することでボタンに色がついて見栄えもよくなります。
第三引数はボタンをタップしたときの挙動であり、着信通知の場合、「応答」には通話画面 (CallActivity) を、「拒否」にはレシーバー (CallRefusedReceiver) をそれぞれ指定します。

5. Notification のフラグ追加

着信通知実装にあたり必要なフラグを Notification に追加付与しています。(XOR で追加できます。)
FLAG_NO_CLEAR が「ユーザーが通知をクリアしてもクリアされないようにする」で、FLAG_INSISTENT が「通知が開かれたりキャンセルされたりするまで音声を繰り返す」的な意味です。
着信通知をユーザーが間違って消せる状態でもダメだし、着信音が途中で鳴らなくなってもダメですよね、ということです。

その他の必要な実装

これで着信通知は一応表示できるようになったのですが、その他にも必要な実装がいくつかあります。

全画面表示のパーミッション追加

まずは AndroidManifest に USE_FULL_SCREEN_INTENT のパーミッションを追加する必要があります。
これは Android 10 以降の仕様変更により、全画面表示をする場合は追加でパーミッションが必要になったためです。*3
なお、このパーミッションの保護レベル *4 は標準の権限 (normal) であるため、リクエストしたアプリに自動で権限付与されます。

通知領域から着信通知を削除

先ほどの実装にて Notification に FLAG_NO_CLEAR フラグを追加しましたが、「ユーザーが通知をクリアしてもクリアされないようにする」ということは、裏を返すと「通知を消したかったらアプリ側で明示的に消す」ということです。
つまり、先ほどのままだと着信通知やそのボタンを押しても着信通知がずっと残ったまま消えなくなってしまいます。
着信画面や通話画面を表示したタイミング (Activity#onCreate)、または拒否レシーバーが検知したタイミング (BroadcastReceiver#onReceive) にて、着信通知を削除する実装を追加しましょう。
また、着信通知は一つのアプリに対して1つしか出さないので、着信通知の notificationId を固定値にしてアプリ側でいつでも着信通知を削除できるようにしておきましょう。

アプリアイコンタップしたときに着信画面を表示

着信通知が出ている状態で、OS のホーム画面やアプリ一覧画面から着信通知を出しているアプリのアイコンをタップしたときの挙動を考えます。
着信通知が消える代わりに着信画面が全面に出ることが望ましいですよね。
つまり、「アプリがフォアグラウンドになったことを検知して、着信通知が出ていれば着信画面を表示して着信通知を削除する」といった実装が必要になります。

まず、「アプリがフォアグラウンドになったことを検知する」実装です。
build.gradle の dependencies に lifecycle-extensions ライブラリを追加することで、ライフサイクル関連の拡張を使えるようにします。(現在の安定版は 2.2.0 です。*5
androidx.lifecycle.LifecycleObserver インターフェースを継承したクラスを実装し、OnLifecycleEvent アノテーションを付与することでアプリの各ライフサイクル(onCreate, onStart, onResume, onPause, onStop, onDestroy)を取得できます。
あとは Application クラスを継承したクラスにライフサイクルの監視を追加し、AndroidManifest に該当の Application クラスを指定します。
これでアプリのライフサイクルをリアルタイムで検知できるようになりました。

次は、「着信通知が出ていれば着信画面を表示して着信通知を削除する」実装です。
ライフサイクルの監視クラスのフォアグラウンド検知メソッド onResume に以下のような細工をします。
(好みによりますが onStart でもよいです。その他の付随するメソッドはサンプル見てください。)
これでアプリがフォアグラウンドになったときに着信通知が表示されていれば削除して着信画面を表示する実装が完成しました。

@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
    logD("onResume()")
    val notification = notificationPostman.findNotification(context, NotificationId.CALL.id)
    if (notification != null) {
        logI("Found call notification. Send fullScreenIntent.")
        try {
            notificationPostman.delete(context, NotificationId.CALL.id)
            notification.fullScreenIntent.send()
        } catch (e : PendingIntent.CanceledException) {
            e.printStackTrace()
        }
    }
}

ここで少し問題になってくるのは、通知領域に表示している通知を取得するために使用しているメソッド NotificationManager#getActiveNotifications が API 23 からの実装であることです。
オンライン診療ポケットドクターの場合は、着信通知を表示するのは Android 10 以降のみと決めているので問題ないですが、Android L 以下もサポートしようとすると問題になってきます。
ただし、すでに Android L の国内シェアが数パーセントまで落ちているため、サポートを切るというのも手かもしれません。(サンプルでは API 23 以上にしています。)

その他 プッシュでやりとりする

着信通知は通知領域に着信専用の通知をただ表示しているだけなので、裏でビデオ通話のコネクションを張ることができません。
例えば WebRTC *6 を用いてビデオ通話を実現している場合、着信通知を表示するタイミングで WebRTC のオブジェクトを生成してコネクションを張ったとしても、着信画面 (Activity) を作成するときにデータではないオブジェクトを Intent に渡せないためです。
(もしかしたら Service を駆使すればいけるのかもしれませんが、ビデオ通話を行う以上リアルタイムで更新される画像を Activity に受け渡して映像にする必要がありますし、、、まあ考えたくないですね。)
というわけで、着信通知がでている間に発信側が発信キャンセルしたときに着信通知が消えなかったり、着信通知の「拒否」ボタンをタップしたときに発信画面を終了したりする処理をリモートコネクション以外の方法で実装する必要がでてきます。

一番簡単な方法は着信のトリガー同様、プッシュでやりとりするです。
発信側が発信キャンセルした場合は、発信キャンセル用の API を叩いて着信側にプッシュを投げて着信通知を削除します。
また、着信通知の「拒否」ボタンをタップしたときには拒否レシーバーで検知できるので、ここで着信拒否用の API を叩いて発信側にプッシュを投げて発信画面を終了します。

終わりに

いかがでしたか?

今回は Android の着信通知について、実装ゴリゴリなお話になりました。
次回は着信通知の調査系の記事を書こうと思います。(ここで言い切っちゃったので近いうちに書きます!年末年始頑張ります!)

来年も OPTiM TECH BLOG を宜しくお願い致します。

それでは皆様、よいお年を!



オンライン診療ポケットドクターはお手持ちのスマホの AppStore もしくは GooglePlay から無料でダウンロードすることが可能です。

オプティムではエンジニアを募集しています。