【Kotlin】1秒ごとに更新される通知の実装
実装したいもの
図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の公式ドキュメント
- HiltのCodelab
Foreground Serviceについては以下のWebページが参考になります。
- ForegroundServiceについてのブログ記事
- Foreground Serviceの公式ドキュメント
Notificationについては以下のWebページが参考になります。
- Notificationの公式ドキュメント
NotificationはAndroid13以降でpermissionのリクエストが必要になりました。それについては以下のWebページが参考になります。
- Android13以降のNotificationのpermissionについて
今回作成したコードのパーミッションのリクエストの部分では以下のコードを参考にしました。
- notificationのpermissionについて参考にしたコード
結論
1秒ごとに更新される通知の実装を行うにはどうすればよいかGoogle Bardに質問してみました。
- Google Bardに1秒ごとに更新される通知の実装方法を質問してみた
結論としては、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)
実装例
図2. 実装アプリのメイン画面
アプリのメイン画面では、タイマーの秒数の指定、タイマーの開始と停止、タイマーの秒数を表示する通知の表示と削除を行うことができます。
図3. 実装アプリのタイマー通知画面(タイマー作動時)
図4. 実装アプリのタイマー通知画面(タイマー停止時)
通知は、図3と図4に示します。図3はタイマーが作動中の通知画面で、STOPボタンとCLOSEボタンが表示されます。図4はタイマーが停止中の通知画面で、STARTボタンとCLOSEボタンが表示されます。通知は秒数の部分が一秒ごとに更新され、現在のタイマーの秒数が表示されます。
通知の表示にはForeground Serviceを用いており、アプリを終了しても動作するようにしています。
コード
build.gradle.kts
plugins {
id("com.google.dagger.hilt.android") version "2.44" apply false
}
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
<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_SERVICE
とPOST_NOTIFICATIONS
のパーミッションを追加します。また、application
のname
を追加し、service
を追加します。Android Studioによって自動で入力される箇所は「…」と記述して省略しています。
VariableNotificationApplication.kt
package com.example.variablenotification
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class VariableNotificationApplication: Application() {
}
Hilt
を利用するため、@HiltAndroidApp
アノテーションを付けたApplication
クラスを用意します。
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
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
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
}
}
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
}
}
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
}
}
CloseTimerService
、StartTimerService
、StopTimerService
は、通知のアクションを実行するためのService
クラスです。CloseTimerService
は通知を削除し、StartTimerService
はタイマーを開始し、StopTimerService
はタイマーを停止します。
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