作成: 2020年06月27日
更新: 2021年01月07日
アンドロイドでサビが流せるアラームアプリを作成しました
ソースコード
アンドロイドアプリ開発が初めてだったので技術的に詰まった点とその解決策を備忘録的に残します.
適当に作っていると1つのファイルにほとんどすべての機能を乗せてしまいそうになり,役割を分担するにしてもどのようにわけたらいいかわからない.検索しても抽象的だったりでわかりにくい.
自分は以下のGithubを参考にFluxアーキテクチャを採用しました.
https://github.com/lgvalle/android-flux-todo-app
古いリポジトリですが機能自体が最小限のためFluxアーキテクチャの実例としてわかりやすかったです.
Fluxアーキテクチャはおおまかに以下のように機能がわかれています.
Action: アラームの作成,削除などを行う際に各所から呼び出す関数群を集めたシングルトンオブジェクト.実際の処理は書かない.
Dispatcher: Actionの関数から呼び出される.アラームの作成,削除が行われたというイベントを発生させる.他のクラスでこのイベントを受け取り,なんらかの処理を走らせることができる.
Store: Dispatcherで起きたイベントに反応してアラームの作成に伴うデータベース操作など実際の処理を走らせる.
View: ユーザーから見えるUIの操作を管理する
以下の記事も参考になりました.
RecyclerViewを用いました.
RecyclerView でリストを作成する | Android デベロッパー | Android Developers
アラームに必要なボタンやスイッチなどの機能を並べました.
sabi_alarm/AlarmRecyclerAdapter.kt at master · bana118/sabi_alarm
TimePickerDialogを用います.
TimePickerDialog | Android デベロッパー | Android Developers
アラーム追加用のボタンを押した際にTimePickerDialogを表示させて,その入力をアラーム作成用の関数に投げます.
add_alarm_button.setOnClickListener {
val calendar = Calendar.getInstance()
val nowHour = calendar.get(Calendar.HOUR_OF_DAY)
val nowMinute = calendar.get(Calendar.MINUTE)
val timePickerDialog = TimePickerDialog(
this,
TimePickerDialog.OnTimeSetListener { _: TimePicker, pickerHour: Int, pickerMinute: Int ->
addAlarm(pickerHour, pickerMinute) // アラームを作成
},
nowHour, nowMinute, true
)
timePickerDialog.show()
}
デフォルトで用意されているAndroidのアラームAPIは音楽の再生開始時間を設定することができません
一般的なインテント | Android デベロッパー | Android Developers
そのためアラームを自作する必要があります.
一定の時間で特定の動作を行わせるには様々なAPIがあります.
反復アラームのスケジュール設定 | Android デベロッパー | Android Developers
しかしAndroid 6.0以上ではDozeモードという一定時間操作をしていないと端末が省電力モードになり通知やアラームを正確に受信しなくなります.
Dozeモードでもアラームを鳴らすにはAlarmManager.setAlarmClockという関数を使用する必要があります.
AlarmManager | Android デベロッパー | Android Developers
この関数自体は指定した時間にイベントを発火させるだけで音楽を鳴らしてくれるわけではないので音楽を鳴らす処理は別途必要になります.またこの関数はリピートオプションがないので毎日鳴らすアラームなどを作成するにはアラームが鳴るたびに次の日のアラームをセットするような処理が必要です.
まとめると以下のような処理になります.
それぞれのコードは以下のようになります(これらだけでは実行できないので参考程度).
アラームをセット
fun setAlarm(id: Int, context: Context) {
val alarm = AlarmStore.alarms.first { it.id == id } // アラームの時間,音楽ファイル名,再生開始時間,繰り返しかどうかなどの情報を持つ
val setTime = LocalTime.of(alarm.hour, alarm.minute)
val nowTime = LocalTime.of(LocalTime.now().hour, LocalTime.now().minute)
val intent = Intent(context, AlarmBroadcastReceiver()::class.java)
intent.putExtra("id", alarm.id)
val pendingIntent = PendingIntent.getBroadcast(
context, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
val alarmTime = //アラームを鳴らす時間をLocalDateTime型で指定
if (setTime.isAfter(nowTime)) {
LocalDateTime.of(LocalDate.now(), setTime)
} else {
LocalDateTime.of(LocalDate.now(), setTime).plusDays(1)
}
val alarmTimeMilli = if(alarm.isRepeatable){
val nextAlarmDayMilli = calcDayOfWeekDiff(alarm, false)
nextAlarmDayMilli + alarmTime.toEpochSecond(OffsetDateTime.now().offset) * 1000 // seconds -> milliseconds
}else{
alarmTime.toEpochSecond(OffsetDateTime.now().offset) * 1000 // seconds -> milliseconds
}
val clockInfo = AlarmManager.AlarmClockInfo(alarmTimeMilli, null) //アラームを鳴らす時間をUNIX時間(ミリ秒込み)で指定
if (alarmManager != null && pendingIntent != null) {
alarmManager.setAlarmClock(
clockInfo,
pendingIntent
)
val formatter = DateTimeFormatter.ofPattern("HH:mm にアラームをセットしました")
val alarmText = alarmTime.format(formatter)
Toast.makeText(context, alarmText, Toast.LENGTH_SHORT).show()
}
}
BroadCastReceiverがアラームを受け取り,同時に次のアラームをセット
class AlarmBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getIntExtra("id", 0)
val startServiceIntent = Intent(context, AlarmSoundService::class.java)
val alarm = AlarmStore.alarms.first{it.id == id}
if(alarm.isRepeatable){
RepeatAlarmManager.nextSetAlarm(id, context) //次のアラームをセット
}
startServiceIntent.putExtra("id", id)
context.startForegroundService(startServiceIntent) //音楽を鳴らすサービスを実行
}
}
fun nextSetAlarm(id: Int, context: Context) {
val alarm = AlarmStore.alarms.first{it.id == id}
val intent = Intent(context, AlarmBroadcastReceiver()::class.java)
intent.putExtra("id", alarm.id)
val pendingIntent = PendingIntent.getBroadcast(
context, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
val nextAlarmTimeMilli = calcDayOfWeekDiff(alarm, true) + System.currentTimeMillis() //次のアラームの時間を計算
val clockInfo = AlarmManager.AlarmClockInfo(nextAlarmTimeMilli, null)
if (alarmManager != null && pendingIntent != null) {
alarmManager.setAlarmClock(
clockInfo,
pendingIntent
)
}
}
音楽を鳴らす
class AlarmSoundService : Service(), MediaPlayer.OnCompletionListener {
lateinit var alarm: Alarm
lateinit var mediaPlayer: MediaPlayer
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
require(intent != null)
val id = intent.getIntExtra("id", 0)
mediaPlayer = MediaPlayer()
alarm = AlarmStore.alarms.first { it.id == id }
val stopSoundActivityIntent = Intent(this, StopAlarmActivity::class.java)
stopSoundActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
stopSoundActivityIntent.putExtra("id", id)
val channelId = getString(R.string.channel_id)
val stopSoundFullScreenIntent = Intent(stopSoundActivityIntent) // アラームを止めるアクティビティ
val stopSoundFullScreenPendingIntent = PendingIntent.getActivity(
this,
id,
stopSoundFullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.mipmap.ic_launcher_round)
.setContentTitle(this.getString(R.string.app_name))
.setContentText("アラーム!")
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setAutoCancel(true)
.addAction(0, "アラームを停止", stopSoundFullScreenPendingIntent)
.setFullScreenIntent(stopSoundFullScreenPendingIntent, true) //アラームを止めるアクティビティへのリンクを通知に表示
if (alarm.isVibration) {
notificationBuilder.setVibrate(longArrayOf(0, 1000, 100))
}
val notification = notificationBuilder.build()
startForeground(1, notification)
play()
return START_NOT_STICKY
}
override fun onDestroy() {
stop()
super.onDestroy()
}
override fun onCompletion(mediaPlayer: MediaPlayer?) {
play()
}
private fun play() {
val fileName = if (alarm.isDefaultSound) {
"default/${alarm.soundFileName}"
} else {
"default/${alarm.soundFileName}"
}
val assetFileDescriptor = this.assets.openFd(fileName)
try {
mediaPlayer.reset()
mediaPlayer.setDataSource(assetFileDescriptor)
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build())
mediaPlayer.prepare()
mediaPlayer.seekTo(alarm.soundStartTime)
mediaPlayer.start()
} catch (e: IOException) {
e.printStackTrace()
}
}
private fun stop() {
mediaPlayer.stop()
mediaPlayer.release()
}
}
Androidではデスクトップアプリのように任意のディレクトリでのファイル一覧を取得することができません.
以前はEnvironment.getExternalStorageDirectory()というメソッドがありましたがdeprecatedになっています.
そこでユーザーがMusicフォルダなどに保存した音楽ファイルを読み込むには 2つの方法があります.
MediaStoreを使ってオーディオファイル一覧を取得する.
ファイル選択のUIを自分で実装することができますが,どこのフォルダにあるかなどは考慮されない,MediaStoreを介さずに保存されたオーディオファイル(PCから転送されたファイルなど)は認識されないという欠点があります.ユーザーに特定のフォルダを指定させ,そこの全ファイルをスキャンし,MediaStoreに登録させれば使えるかもしれませんが今回は使いませんでした.
SAF( https://developer.android.com/guide/topics/providers/document-provider?hl=ja )を介してファイルを選択してもらう
ストレージアクセスフレームワーク(SAF)というAndroidが用意したUIを介して音楽ファイルを選択してもらい,そのファイルのURIを取得します.
UIの統一感はなくなりますがディレクトリ構造に基づいた自然な形で音楽ファイルを選択できます.今回はこの方法を用います.
SAFの起動方法は以下
add_local_sound_button.setOnClickListener {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "audio/*" // mp3, wavなど音楽ファイルのみ選択できるようにする
}
startActivityForResult(intent, readRequestCode) // SAF画面に遷移
}
ユーザーが選択した音楽ファイルのURIは遷移元のActivityのonActivityResultメソッドで取得できます.
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == readRequestCode && resultCode == Activity.RESULT_OK) {
resultData?.data?.also {uri -> // URI取得
val fileName = getFileName(uri) // ファイル名取得
check(fileName is String){ "FileName must be String"}
this.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) // 読み取り権限の取得
SoundActionsCreator.add(fileName, uri.toString(), this) // ファイル名とURIを保存
localSoundAdapter.notifyDataSetChanged()
}
}
}
アラームを鳴らす際は取得したURIをMediaPlayerにセットすれば鳴らせます.