🔔

【Kotlin】1秒ごとに更新される通知の実装

2023/08/25に公開

実装したいもの

図1. Google時計アプリのタイマーの通知
図1. Google時計アプリのタイマーの通知
Googleの時計アプリにおいて、タイマーを起動中にアプリを閉じると、上記の画像の通知が表示されます。この通知では、タイマーの経過秒数に応じて内容が更新されます。こうした特定の秒数ごとに変化する機能を実装していきます。

想定環境

使用言語: Kotlin
Android Studio Giraffe | 2022.3.1
OS: Windows 11
minSdkVersion: 26
targetSdkVersion: 33

対象読者

  • Hiltを使える
  • Notificationを使える
  • Foreground Serviceを使える
  • Jetpack Composeを使える

文献リスト

Hilt、Notification、Foreground Serviceについて、今回は詳細な説明は省きます。ただし、これらの技術については、文献を参照していただければ幸いです。

文献リスト

Hiltについては以下のWebページが参考になります。

  • Hiltの公式ドキュメント

https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

  • HiltのCodelab

https://developer.android.com/codelabs/android-hilt?hl=ja
Foreground Serviceについては以下のWebページが参考になります。

  • ForegroundServiceについてのブログ記事

https://unluckysystems.com/バックグラウンドで動作させるためのフォアグラ/

  • Foreground Serviceの公式ドキュメント

https://developer.android.com/guide/components/foreground-services
Notificationについては以下のWebページが参考になります。

  • Notificationの公式ドキュメント

https://developer.android.com/training/notify-user/build-notification?hl=ja
NotificationはAndroid13以降でpermissionのリクエストが必要になりました。それについては以下のWebページが参考になります。

  • Android13以降のNotificationのpermissionについて

https://www.fuwamaki.com/article/344
今回作成したコードのパーミッションのリクエストの部分では以下のコードを参考にしました。

  • notificationのpermissionについて参考にしたコード

https://github.com/firebase/quickstart-android/blob/009d0c9eb04db335a5400e23546d567f5613b941/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MainActivity.kt#

結論

1秒ごとに更新される通知の実装を行うにはどうすればよいかGoogle Bardに質問してみました。

  • Google Bardに1秒ごとに更新される通知の実装方法を質問してみた

https://g.co/bard/share/81835ba1ef7b
結論としては、1秒ごとに同じ通知IDの通知を行うというものです。

// 通知を作成する
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
  .setContentTitle("My Notification")
    .setContentText("This is my notification.")
    .setSmallIcon(R.drawable.ic_notification)
    .build()

// 通知を更新するタイマーを作成します。
val timer = Timer()
timer.scheduleAtFixedRate(object : TimerTask() {
    override fun run() {
        // 通知を更新する
        notificationManager.notify(NOTIFICATION_ID, notification)
    }
}, 0, 1000)

実装例

https://github.com/Kei-no-suke/android-compose-variable-notification/tree/main

図2. 実装アプリのメイン画面
図2. 実装アプリのメイン画面
アプリのメイン画面では、タイマーの秒数の指定、タイマーの開始と停止、タイマーの秒数を表示する通知の表示と削除を行うことができます。
図3. 実装アプリのタイマー通知画面(タイマー作動時)
図3. 実装アプリのタイマー通知画面(タイマー作動時)
図4. 実装アプリのタイマー通知画面(タイマー停止時)
図4. 実装アプリのタイマー通知画面(タイマー停止時)
通知は、図3と図4に示します。図3はタイマーが作動中の通知画面で、STOPボタンとCLOSEボタンが表示されます。図4はタイマーが停止中の通知画面で、STARTボタンとCLOSEボタンが表示されます。通知は秒数の部分が一秒ごとに更新され、現在のタイマーの秒数が表示されます。
通知の表示にはForeground Serviceを用いており、アプリを終了しても動作するようにしています。

コード

build.gradle.kts

build.gradle.kts(Project)
plugins {
  id("com.google.dagger.hilt.android") version "2.44" apply false
}
build.gradle.kts(Module)
plugins {
    kotlin("kapt")
    id("com.google.dagger.hilt.android")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.44")
    kapt("com.google.dagger:hilt-android-compiler:2.44")
}

kapt {
    correctErrorTypes = true
}

Hiltを使用するためにプロジェクトのbuild.gradle.ktsファイルとアプリモジュールのbuild.gradle.ktsファイルに以上の内容を追加します。

AndroidManifest.xml

AndroidManifest.xml
<manifest
...>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <application
        android:name=".VariableNotificationApplication"
	...>
        ...
	<service android:name=".CloseTimerService"
            android:enabled="true"
	    android:exported="false"/>
        <service android:name=".VariableNotificationService"
	    android:enabled="true"
	    android:exported="false"/>
        <service android:name=".StopTimerService"
	    android:enabled="true"
	    android:exported="false"/>
	<service android:name=".StartTimerService"
	    android:enabled="true"
	    android:exported="false"/>
    </application>
</manifest>

AndroidManifest.xmlに、FOREGROUND_SERVICEPOST_NOTIFICATIONSのパーミッションを追加します。また、applicationnameを追加し、serviceを追加します。Android Studioによって自動で入力される箇所は「…」と記述して省略しています。

VariableNotificationApplication.kt

VariableNotificationApplication.kt
package com.example.variablenotification

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class VariableNotificationApplication: Application() {
}

Hiltを利用するため、@HiltAndroidAppアノテーションを付けたApplicationクラスを用意します。

TimerOperator.kt

TimerOperator.kt
package com.example.variablenotification

import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class TimerOperator @Inject constructor() {
    var timerSeconds: Long = 0
    private var timer: Timer? = null
    var isStart: Boolean = false

    fun setTimer(seconds: Long) {
        timerSeconds = seconds
    }

    fun timerStart() {
        isStart = true
        if(timer != null){
            timer!!.cancel()
            timer = null
        }
        timer = Timer()
        timer!!.scheduleAtFixedRate(object: TimerTask(){
            override fun run() {
                if(timerSeconds > 0){
                    timerSeconds--
                }
            }
        },0,1000)
    }

    fun timerStop() {
        isStart = false
        if(timer != null){
            timer!!.cancel()
            timer = null
        }
    }
}

共通のTimerOperatorクラスをMainActivityクラスとその他のServiceクラスで使用します。

VariableNotificationService.kt

VariableNotificationService.kt
package com.example.variablenotification

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject

const val CHANNEL_ID = "VARIABLE_NOTIFICATION"
const val CHANNEL_NAME = "Foreground Channel"
const val FOREGROUND_ID = 2

@AndroidEntryPoint
class VariableNotificationService: Service() {
    @Inject
    lateinit var timerOperator: TimerOperator
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        val startTimerIntent = Intent(applicationContext, StartTimerService::class.java)
        val stopTimerIntent = Intent(applicationContext, StopTimerService::class.java)
        val closeTimerIntent = Intent(applicationContext, CloseTimerService::class.java)

        val startTimerPendingIntent = PendingIntent.getService(
            applicationContext,
            0,
            startTimerIntent,
            PendingIntent.FLAG_IMMUTABLE
        )

        val stopTimerPendingIntent = PendingIntent.getService(
            applicationContext,
            0,
            stopTimerIntent,
            PendingIntent.FLAG_IMMUTABLE
        )

        val closeTimerPendingIntent = PendingIntent.getService(
            applicationContext,
            0,
            closeTimerIntent,
            PendingIntent.FLAG_IMMUTABLE
        )

        updateNotification(
            startTimerPendingIntent,
            stopTimerPendingIntent,
            closeTimerPendingIntent
        )

        return START_NOT_STICKY
    }

    private fun updateNotification(
        startTimerPendingIntent: PendingIntent,
        stopTimerPendingIntent: PendingIntent,
        closeTimerPendingIntent: PendingIntent
    ){
        val timer = Timer()
        timer.scheduleAtFixedRate(object : TimerTask(){
            override fun run() {
                if(timerOperator.isStart){
                    startForeground(
                        FOREGROUND_ID,
                        notification(
                            "${timerOperator.timerSeconds} seconds",
                            stopTimerPendingIntent,
                            closeTimerPendingIntent
                        )
                    )
                }else{
                    startForeground(
                        FOREGROUND_ID,
                        notification(
                            "${timerOperator.timerSeconds} seconds",
                            startTimerPendingIntent,
                            closeTimerPendingIntent
                        )
                    )
                }

            }
        },0, 1000)
    }

    private fun notification(
        title: String,
        operateTimerPendingIntent: PendingIntent,
        closeTimerPendingIntent: PendingIntent
    ) : Notification {
        val contentText = "Variable Notification"
        val channel = NotificationChannel(
            CHANNEL_ID, CHANNEL_NAME,
            NotificationManager.IMPORTANCE_LOW
        )
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        manager.createNotificationChannel(channel)

        val operateTimerText = if(timerOperator.isStart){
            "stop"
        }else{
            "start"
        }

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle(title)
            .setContentText(contentText)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .setOngoing(true)
            .addAction(R.drawable.ic_launcher_foreground, operateTimerText, operateTimerPendingIntent)
            .addAction(R.drawable.ic_launcher_foreground, "close", closeTimerPendingIntent)

        return notification.build()
    }
}

fun startNotifyForegroundService(context: Context) {
    ContextCompat.startForegroundService(
        context,
        Intent(context, VariableNotificationService::class.java)
    )
}

fun stopNotifyForegroundService(context: Context) {
    val targetIntent = Intent(context, VariableNotificationService::class.java)
    context.stopService(targetIntent)
}

このServiceクラスはForeground Service用のクラスで、以下のコードはこのServiceクラスをForeground Serviceとして呼ぶための関数です。

fun startNotifyForegroundService(context: Context) {
    ContextCompat.startForegroundService(context, Intent(context, VariableNotificationService::class.java))
}

fun stopNotifyForegroundService(context: Context) {
    val targetIntent = Intent(context, VariableNotificationService::class.java)
    context.stopService(targetIntent)
}

このServiceクラス内のupdateNotification関数が通知を更新する役割を果たしています。

private fun updateNotification(
    startTimerPendingIntent: PendingIntent,
    stopTimerPendingIntent: PendingIntent,
    closeTimerPendingIntent: PendingIntent
){
    val timer = Timer()
    timer.scheduleAtFixedRate(object : TimerTask(){
        override fun run() {
            if(timerOperator.isStart){
                startForeground(
                    FOREGROUND_ID,
                    notification(
                        "${timerOperator.timerSeconds} seconds",
                        stopTimerPendingIntent,
                        closeTimerPendingIntent
                    )
                )
            }else{
                startForeground(
                    FOREGROUND_ID,
                    notification(
                        "${timerOperator.timerSeconds} seconds",
                        startTimerPendingIntent,
                        closeTimerPendingIntent
                    )
                )
            }

        }
    },0, 1000)
}

通知のアクションボタン用のService

CloseTimerService.kt
package com.example.variablenotification

import android.app.Service
import android.content.Intent
import android.os.IBinder

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

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        stopNotifyForegroundService(applicationContext)

        return START_NOT_STICKY
    }
}
StartTimerService.kt
package com.example.variablenotification

import android.app.Service
import android.content.Intent
import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class StartTimerService: Service() {
    @Inject lateinit var timerOperator: TimerOperator
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        timerOperator.timerStart()

        return START_NOT_STICKY
    }
}
StopTimerService.kt
package com.example.variablenotification

import android.app.Service
import android.content.Intent
import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class StopTimerService: Service() {
    @Inject lateinit var timerOperator: TimerOperator
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        timerOperator.timerStop()

        return START_NOT_STICKY
    }
}

CloseTimerServiceStartTimerServiceStopTimerServiceは、通知のアクションを実行するためのServiceクラスです。CloseTimerServiceは通知を削除し、StartTimerServiceはタイマーを開始し、StopTimerServiceはタイマーを停止します。

MainActivity.kt

MainActivity.kt
package com.example.variablenotification

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import com.example.variablenotification.ui.theme.VariableNotificationTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject lateinit var timerOperator: TimerOperator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val requestPermissionLauncher = registerForActivityResult(
                ActivityResultContracts.RequestPermission(),
            ) { isGranted: Boolean ->
                if (isGranted) {
                    Toast.makeText(
                        this,
                        "通知:許可済",
                        Toast.LENGTH_SHORT
                    ).show()
                } else {
                    Toast.makeText(
                        this,
                        "通知:未許可",
                        Toast.LENGTH_SHORT,
                    ).show()
                }
            }
            if (ActivityCompat.checkSelfPermission(
                    this,
                    Manifest.permission.POST_NOTIFICATIONS
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
        setContent {
            VariableNotificationTheme {
                var seconds = remember {
                    mutableStateOf("")
                }

                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    InputField(seconds, timerOperator, applicationContext)
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InputField(
    seconds: MutableState<String>,
    timerOperator: TimerOperator,
    context: Context,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .padding(20.dp)
    ) {
        Row {
            Text(
                text = "秒数を\n入力してください",
                modifier = Modifier.weight(3f)
            )
            Spacer(
                modifier = Modifier.weight(1f)
            )
            TextField(
                value = seconds.value,
                onValueChange = {seconds.value = it},
                modifier = Modifier.weight(3f)
            )
        }
        Spacer(modifier = Modifier.height(20.dp))
        Text("通知")
        Row {
            Spacer(modifier = Modifier.weight(1f))
            Button(
                onClick = { startNotifyForegroundService(context) },
                modifier = Modifier.weight(2f)
            ) {
                Text("表示")
            }
            Spacer(modifier = Modifier.weight(1f))
            Button(
                onClick = { stopNotifyForegroundService(context) },
                modifier = Modifier.weight(2f)
            ) {
                Text("削除")
            }
            Spacer(modifier = Modifier.weight(1f))
        }
        Spacer(modifier = Modifier.height(20.dp))
        Text("タイマー")
        Row {
            Spacer(modifier = Modifier.weight(1f))
            Button(
                onClick = {
                    timerOperator.setTimer(seconds.value.toLongOrNull() ?: 0)
                    timerOperator.timerStart()
                },
                modifier = Modifier.weight(2f)
            ) {
                Text("開始")
            }
            Spacer(modifier = Modifier.weight(1f))
            Button(
                onClick = { timerOperator.timerStop() },
                modifier = Modifier.weight(2f)
            ) {
                Text("停止")
            }
            Spacer(modifier = Modifier.weight(1f))
        }
    }
}

アプリのメイン画面は、Jetpack Composeを使用してUIを実装したMainActivityクラスで実装されています。また、MainActivityクラスでは、以下のコードで通知の許可のリクエストを行っています。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { isGranted: Boolean ->
        if (isGranted) {
            Toast.makeText(
                this,
                "通知:許可済",
                Toast.LENGTH_SHORT
            ).show()
        } else {
            Toast.makeText(
                this,
                "通知:未許可",
                Toast.LENGTH_SHORT,
            ).show()
        }
    }
    if (ActivityCompat.checkSelfPermission(
            this,
            Manifest.permission.POST_NOTIFICATIONS
        ) != PackageManager.PERMISSION_GRANTED
    ) {
        requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
    }
}

まとめ

本記事では、Google Bardに1秒ごとに更新される通知の実装方法について質問し、その回答を参考に、タイマーの秒数を表示する通知の実装例を示しました。
本記事の内容が、タイマーアプリやその他のアプリ開発における1秒ごとに更新される通知の実装に役立てば幸いです。また、本記事についてご意見やご感想があれば、コメント欄に書き込んでいただければ幸いです。

Discussion