アンドロイドで好きな音楽のサビを流せるアラームアプリを作る

作成: 2020年06月27日

更新: 2021年01月07日

概要

アンドロイドでサビが流せるアラームアプリを作成しました
google-play-badge.png
ソースコード

アンドロイドアプリ開発が初めてだったので技術的に詰まった点とその解決策を備忘録的に残します.

ディレクトリ構成がわからない

適当に作っていると1つのファイルにほとんどすべての機能を乗せてしまいそうになり,役割を分担するにしてもどのようにわけたらいいかわからない.検索しても抽象的だったりでわかりにくい.

自分は以下のGithubを参考にFluxアーキテクチャを採用しました.

https://github.com/lgvalle/android-flux-todo-app

古いリポジトリですが機能自体が最小限のためFluxアーキテクチャの実例としてわかりやすかったです.

Fluxアーキテクチャはおおまかに以下のように機能がわかれています.

  • Action: アラームの作成,削除などを行う際に各所から呼び出す関数群を集めたシングルトンオブジェクト.実際の処理は書かない.

  • Dispatcher: Actionの関数から呼び出される.アラームの作成,削除が行われたというイベントを発生させる.他のクラスでこのイベントを受け取り,なんらかの処理を走らせることができる.

  • Store: Dispatcherで起きたイベントに反応してアラームの作成に伴うデータベース操作など実際の処理を走らせる.

  • View: ユーザーから見えるUIの操作を管理する

    以下の記事も参考になりました.

    GitHubで人気なFlux(とMVI)のAndroidアプリのコードを読んでみるメモ - Qiita

作成したアラームをリスト上に並べる

RecyclerViewを用いました.

RecyclerView でリストを作成する | Android デベロッパー | Android Developers

RecyclerViewの基本 - Qiita

アラームに必要なボタンやスイッチなどの機能を並べました.

sabi_alarm/AlarmRecyclerAdapter.kt at master · bana118/sabi_alarm

アラーム作成時に時計型の入力UIを用いる

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

この関数自体は指定した時間にイベントを発火させるだけで音楽を鳴らしてくれるわけではないので音楽を鳴らす処理は別途必要になります.またこの関数はリピートオプションがないので毎日鳴らすアラームなどを作成するにはアラームが鳴るたびに次の日のアラームをセットするような処理が必要です.

まとめると以下のような処理になります.

  1. アラーム作成時にアラームが鳴る時間を計算しsetAlarmClock関数を実行する
  2. 指定した時間にBroadcastReceiverがイベントの発火を検知し音楽を鳴らすサービスを実行し,同時に繰り返したいアラームの場合次のアラームをセットする
  3. BroadcastReceiverから呼ばれたサービスがMediaPlayerを用いて指定された音楽ファイル,再生開始時間をセットし音楽を鳴らす.また同時に自動で消えない通知を表示し,そこをタッチしたら音楽を停止できるようにする.

それぞれのコードは以下のようになります(これらだけでは実行できないので参考程度).

  1. アラームをセット

    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()
        }
    }
  2. 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
            )
        }
    }
  3. 音楽を鳴らす

    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つの方法があります.

  1. MediaStoreを使ってオーディオファイル一覧を取得する.

    ファイル選択のUIを自分で実装することができますが,どこのフォルダにあるかなどは考慮されない,MediaStoreを介さずに保存されたオーディオファイル(PCから転送されたファイルなど)は認識されないという欠点があります.ユーザーに特定のフォルダを指定させ,そこの全ファイルをスキャンし,MediaStoreに登録させれば使えるかもしれませんが今回は使いませんでした.

  2. SAF( https://developer.android.com/guide/topics/providers/document-provider?hl=ja )を介してファイルを選択してもらう

    ストレージアクセスフレームワーク(SAF)というAndroidが用意したUIを介して音楽ファイルを選択してもらい,そのファイルのURIを取得します.

    saf.png

    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にセットすれば鳴らせます.