Androidでの着信通知で必要なこと

こんにちは。エキサイトでAndroid開発に携わっている伊藤（克）です。
こちらは [XTechグループ 2 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/xtech-group2) の14日目の記事です。

# はじめに
ただ単にAndroidで通知を表示したいだけであれば単純ですが、場合によっては考慮しなければならないことがいくつかあります。
今回は通話着信時の通知を仮定して、順に進めていこうと思います。

通知に関する詳細な情報については[公式のドキュメント](https://developer.android.com/guide/topics/ui/notifiers/notifications)も参照ください。

# 着信通知の表示
着信時の通知表示について以下で実装していきます。

## 1. 通知の表示
まずはただ単純に通知を表示してみます。
Android 8からは[通知チャンネル](https://developer.android.com/training/notify-user/channels)の対応が必須なため、こちらも合わせて設定します。

```MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val notifications = getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager

        // Android 8以降の場合は通知チャンネルを作成
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                    "incoming_call", // チャンネルのID
                    "通話着信の通知", // チャンネルの表示名
                    NotificationManager.IMPORTANCE_DEFAULT // チャンネルの重要度
            )
            notifications.createNotificationChannel(channel)
        }

        // 通知を表示
        val notification = NotificationCompat.Builder(this, "incoming_call")
                .setSmallIcon(R.drawable.ic_call)
                .setContentText("着信通知")
                .build()
        notifications.notify(1, notification)
    }
}
```
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148091/8222f123-d547-01d9-a56e-08dad6158c9d.png">

## 2. 通知のライフサイクル
着信通知の場合、通知には着信開始（着信した）から着信終了（着信に応答したなど）までのライフサイクルが存在します。
前項では`Activity`で通知を表示しましたが、着信に画面（`Activity`）はなくても問題ないはずです。
Androidにはバックグラウンドで処理を実行し、その状態をユーザに表示するための[ForegroundService](https://developer.android.com/guide/components/foreground-services)というものが存在します。
着信中に通知を表示するように変更してみましょう。

```IncomingCallService.kt
class IncomingCallService : Service() {

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onCreate() {
        super.onCreate()

        /* 通知チャンネルを作成 */

        startForeground(1, /* Notification */) // ① ServiceをForegroundServiceで実行する
    }
}
```

```AndroidManifest.xml
<!-- Android 9からはForegroundServiceの使用に宣言が必要 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
```

**① ServiceをForegroundServiceで実行する**
`Service`を起動した後、`startForeground`メソッドを実行することでServiceは`ForegroundService`として扱われるようになります。
引数に渡した通知が`ForegroundService`の実行中に表示されます。

これにより、着信時に`Service`を起動し不要になったら`Service`を破棄することで、ライフサイクルに合わせて通知が管理されるようになりました。

## 3. 通知のアクション
通知は表示できましたが、通知から直接に応答や拒否などの操作を行いたいものです。
通知にアクションを追加してみます。

```IncomingCallService.kt
class IncomingCallService : Service() {

    companion object {

        private const val ACTION_ACCEPT = "ACTION_ACCEPT"
        private const val ACTION_REJECT = "ACTION_REJECT"
    }

    override fun onCreate() {
        super.onCreate()

        /* 通知チャンネルを作成 */

        // 通知を表示
        val notification = NotificationCompat.Builder(this, "incoming_call")
                .setSmallIcon(R.drawable.ic_call)
                .setContentText("着信通知")
                // ① 通知にアクションを設定する
                .addAction(createAcceptAction())
                .addAction(createRejectAction())
                .build()
        startForeground(1, notification)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // ② 通知のアクションから実行される
        when (intent?.action) {
            ACTION_ACCEPT -> {
                /* 着信に応答する処理 */
            }
            ACTION_REJECT -> {
                /* 着信を拒否する処理 */
            }
        }
        return super.onStartCommand(intent, flags, startId)
    }

    private fun createAcceptAction(): NotificationCompat.Action {
        val intent = Intent(this, IncomingCallService::class.java)
                .setAction(ACTION_ACCEPT)
        val pendingIntent = createServiceIntent(intent)
        return NotificationCompat.Action.Builder(R.drawable.ic_call, "応答", pendingIntent)
                .build()
    }

    private fun createRejectAction(): NotificationCompat.Action {
        val intent = Intent(this, IncomingCallService::class.java)
                .setAction(ACTION_REJECT)
        val pendingIntent = createServiceIntent(intent)
        return NotificationCompat.Action.Builder(R.drawable.ic_call, "拒否", pendingIntent)
                .build()
    }

    private fun createServiceIntent(intent: Intent): PendingIntent {
        return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
    }
}
```

**① 通知にアクションを設定する**
通知のアクションには`PendingIntent`を使用します。
`Intent`に起動先を設定し、それを`PendingIntent`に詰めて通知のアクションに設定します。
今回は自身の`Service`を起動先に指定します。

※ Android 7からは通知のアクションに設定したアイコンは基本的に使用されませんが、古いOSやAndroid Wearでは表示されるため必要です。
https://android-developers.googleblog.com/2016/06/notifications-in-android-n.html

**② 通知のアクションから実行される**
`PendingIntent`経由で`onStartCommand`が実行されます。
選択されたアクションに応じた処理をここで実行します。

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148091/81b8803c-8f66-3e6b-bd00-49e5fed58a64.png">

## 4. ヘッドアップ通知
通知は表示しましたが今のままでは通知欄にさり気なく表示されるだけなので、着信があったことに気づかないかもしれません。
通知の重要度を上げると、ヘッドアップ通知と呼ばれるものになり、画面上部に覆い被さるように通知が表示されます。
通常の通知よりも目立つことになるので、着信などの重要な通知の場合にはこちらの設定をしておきましょう。

```IncomingCallService.kt

class IncomingCallService : Service() {

    ~~~

    override fun onCreate() {
        super.onCreate()

        // 通知チャンネルを作成
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                    "incoming_call",
                    "通話の着信通知",
                    NotificationManager.IMPORTANCE_HIGH // ① 通知チャンネルの重要度
            )
            val notifications = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
            notifications.createNotificationChannel(channel)
        }

        // 通知を表示
        val notification = NotificationCompat.Builder(this, "incoming_call")
                .setSmallIcon(R.drawable.ic_call)
                .setContentText("着信通知")
                .setPriority(NotificationCompat.PRIORITY_HIGH) // ② 通知の重要度
                .setCategory(NotificationCompat.CATEGORY_CALL) // ③ 通知のカテゴリ
                .addAction(createAcceptAction())
                .addAction(createRejectAction())
                .build()
        startForeground(1, notification)
    }

    ~~~

}
```

**① 通知チャンネルの重要度**
**② 通知の重要度**
重要度を上げるとヘッドアップ通知と呼ばれるものになり、画面上部に覆いかぶさるように通知が表示されます。
通知チャンネルが存在するAndroid 8以上の場合は通知チャンネルの重要度、そうでないAndroid 7以下の場合は通知の重要度が適用されるので両方を設定しておきましょう。

**③ 通知のカテゴリ**
通知には`CATEGORY_ALARM`や`CATEGORY_REMINDER`などのシステム上の種別を設定することができます。
種別を設定しておくことで、サイレントモードでも適切に通知を表示することができます。
`CATEGORY_CALL`も用意されているので、今回は着信通知ということでこちらを設定しておきましょう。

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148091/ea692a2a-87fc-8894-a4d1-718bd46aba5a.png">

# 着信画面の表示
着信時の通知については今までの実装で一通り形になりました。
しかし通知だけでは表現できる内容に限りがあります。
例えば通話の着信であればマイクの権限を取得するために画面が必要になることもあるでしょう。
以下では着信画面を表示する場合の実装を追加していきます。

## 1. 通知からの画面起動
単純に通知から画面を起動する場合は、通知に`Activity`の`Intent`を設定すれば実現可能です。

```IncomingCallService.kt
val notification = NotificationCompat.Builder(this, "incoming_call")
        .setSmallIcon(R.drawable.ic_call)
        .setContentText("着信通知")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setCategory(NotificationCompat.CATEGORY_CALL)
        .addAction(createAcceptAction())
        .addAction(createRejectAction())
        .setContentIntent(createActivityIntent()) // ① 通知タップ時の設定
        .build()

private fun createActivityIntent(): PendingIntent {
    val intent = Intent(this, MainActivity::class.java)
    return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
```

**① 通知タップ時の設定**
起動する画面の`PendingIntent`を設定します。
今回は仮として`MainActivity`（初期画面）を使用しています。

この実装により、通知をタップすると画面が起動するようになります。

## 2. FullscreenIntentの使用
目覚まし時計や通話の着信など、ユーザが即時に対応する必要のある通知には`FullscreenIntent`を使用することができます。
`FullscreenIntent`には下記の特徴があります。

* `ContentIntent`を設定したときと同様に、通知のタップ時に画面を起動する 
* ヘッドアップ通知に近い表示だが、アクションが強調され**画面上部に表示され続ける**

余談ではありますが、私は当初`FullscreenIntent`という名称やドキュメントの内容から「通知の表示時に`FullscreenIntent`に指定した画面を起動する」という認識でいました。
しかし実態は上記の通り「通知を画面上部に表示し続け、通知のタップ時に`FullscreenIntent`に指定した画面を起動する」というもので、イメージとは異なる挙動に悩まされました。 :thinking:

```IncomingCallService.kt
val notification = NotificationCompat.Builder(this, "incoming_call")
        .setSmallIcon(R.drawable.ic_call)
        .setContentText("着信通知")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setCategory(NotificationCompat.CATEGORY_CALL)
        .addAction(createAcceptAction())
        .addAction(createRejectAction())
        .setFullScreenIntent(createActivityIntent(), true) // ① FullscreenIntentを設定
        .build()
```

```AndroidManifest.xml
<!-- Android 10からはFullScreenIntentの使用に宣言が必要 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
```

**① FullscreenIntentを設定**
前項での`ContentIntent`の設定と同様に、起動する画面の`PendingIntent`を設定します。

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148091/0e535234-5cc7-057d-9e04-a3cb9fcd874c.png">

# まとめ
着信通知を行う場合の要点を下記にまとめます。

* ForegroundServiceで通知のライフサイクルを管理する
* 通知にアクションを設定して通知から対応できるようにする
* 通知のカテゴリと重要度を適切に設定する
* FullscreenIntentを使用して即時性を高める

通話の着信のようにユーザが即時に対応する必要がある通知では、それを容易にするための設定をしておくことが重要です。

ただし、通知の重要度を不当に高くしたりむやみにヘッドアップ通知を多様したりすることは**ユーザの体験を損ねアプリの忌避に繋がる**ので、用途に合った設定をするようにしましょう。

用法用量を守って素敵なAndroidライフを！

***

エキサイトではステキな仲間たちを募集中です :relaxed:
https://www.wantedly.com/companies/excite