Universal Android Music Player(UAMP)のカスタマイズ （音源の変更　2/2）

#はじめに
Android用アプリのプログラミングを学ぶため、Googleが公開しているサンプルコード　**Universal Android Music Player(UAMP)**をカスタマイズしてみます。
まずは、端末（アプリを実行するスマートフォンなど）内の好きな音楽ファイルを再生するように変更します。

[Universal Android Music Player(UAMP)のカスタマイズ （1/2）](https://qiita.com/Jin_Kubota/items/fc94ecd12bc069a58926)で、オリジナルコードでの楽曲カタログ取得からプレイヤーへの設定までの処理の流れを確認し、音源を端末内の音楽ファイルへ変更するカスタマイズの方法を検討しました。
今回は、コードの変更について記載します。


#環境
####PC
MacBook Pro 16
2.3 GHz 8コアIntel Core i9
16 GB 2667 MHz DDR4
macOS Big Sur ver.11.5.2

####開発用SW
Android Studio 4.2.1

####Target Device (Virtual Device)
Category: Phone
Name: Pixcel 2
Resolution: 1080x1920 420bpi
API Level: 28
Android: 9.0
CPU: x86


#UAMPの音源の変更
UAMPの音源を、リモートサーバーから端末内の音楽ファイルへ変更するために新たに、端末内の音楽メディアファイルの情報からExoPlayerで利用可能な形態の楽曲情報のリスト（AbstractMusicSource()）を生成するクラス（MusicCatalog)を作成して、オリジナルコードの楽曲情報リストを生成するクラス（JsonSource)と置き換える。MusicCatalogでは、端末内の音楽メディアファイルの情報の収集に、MediaStoreを利用する。
オリジナルのコードでは、アルバムのイメージをリモートサーバーから取得し、メニュー画面のアイコンや再生画面に使用している。MediaStoreから取得する楽曲の情報には画像の情報が含まれないため、代わりに適当な画像ファイル（下記のコードでは、icon_audio_file.pngとicon_dj.png）を/common/res/drawable/の下に用意し、これを使用する。

```kotlin:MusicCatalog.kt
//ExoPlayerで利用可能な形態の楽曲情報のリストと、リストの状態を示すフラグを保持するクラス。
//オリジナルコードのJsonSourceと同様に、class AbstractMusicSourceを継承する。
//関数load()は、MediaStoreから端末内の音楽ファイルの情報を取得して、ExoPlayerで利用可能な形態に変換する。
//このMusicCatalogで、オリジナルのコードのJsonSourceを置き換える。
class MusicCatalog (application: Application):AbstractMusicSource(){
    private val context = application

    private var audioCatalog: List<MediaMetadataCompat> = emptyList()

    init{
        state = STATE_INITIALIZING
    }

    override fun iterator(): Iterator<MediaMetadataCompat> = audioCatalog.iterator()

    //MediaStoreから楽曲のカタログを取得して、ExoPlayerで利用可能な形態の楽曲情報のリストを作成する関数
    override suspend fun load() {
        //楽曲カタログの取得と変換が正常に完了したとき（関数updateCatalog()の戻り値がnullではないとき）は、
        //取得した情報をMusicCatalogに適用する。
        //また、MusicCatalogの状態を示す変数も更新する。
        updateCatalog()?.let { updateCatalog ->
            //楽曲カタログの取得に成功したときの処理。
            audioCatalog = updateCatalog
            state = STATE_INITIALIZED
        } ?: run {
            //楽曲カタログの取得に失敗したときの処理。
            audioCatalog = emptyList()
            state = STATE_ERROR
        }
    }

    private suspend fun updateCatalog(): List<MediaMetadataCompat>? {
        //MediaStoreから楽曲カタログを取得する。
        val musicCat = queryAudio(context)

        //取得した楽曲カタログ内の楽曲の情報の各項目をExoPlayerで利用可能な楽曲情報の項目にマッピングして、
        //楽曲情報のリストを作成する。
        val mediaMetadataCompats = musicCat.map { song ->

            //取得した楽曲カタログ内の楽曲の情報の各項目をExoPlayerで利用可能な楽曲情報の項目にマッピング
            //する。
            MediaMetadataCompat.Builder()
                //songを拡張して、MediaMetadataCompatの各フィールドを設定する。
                .from(song)
                //メニューや通知の表示するアイコンのURI（displayIcon)と、再生画面で表示するイメージのURI
                //（albumArtUri）を設定する。
                //オリジナルのコードでは、取得した楽曲情報内のアルバムイメージのURIを設定していたが、
                //MediaStoreから取得する楽曲カタログにはイメージのURIがないので、適当なイメージファイルを
                //drawableに用意し、このURIを設定する。
                .apply {
                    val displayIconUriStr = "android.resource://${context.packageName}/${R.drawable.icon_audio_file}"
                    displayIconUri = displayIconUriStr
                    val albumArtUriStr = "android.resource://${context.packageName}/${R.drawable.icon_dj}"
                    albumArtUri = albumArtUriStr
                }
                .build()
        }.toList()
        //リストの各要素に対して、descriptionのextrasにbundleに保存されている値をdescriptionに付加する。
        mediaMetadataCompats.forEach { it.description.extras?.putAll(it.bundle) }
        mediaMetadataCompats

        return mediaMetadataCompats
    }


    //MediaProviderから端末内の音楽ファイルの情報を取得し、リストとして返す。
    private suspend fun queryAudio(context: Context): List<MediaStoreAudio> {

        val audioList = mutableListOf<MediaStoreAudio>()
        //  IO処理用に予約されたスレッドへコンテキストを切り替えて実行。
        withContext(Dispatchers.IO) {
            //データを取得するMediaProviderのカラム
            val projection = arrayOf(
                MediaStore.Audio.Media._ID,
                MediaStore.Audio.Media.DISPLAY_NAME,
                MediaStore.Audio.Media.IS_MUSIC,
                MediaStore.Audio.Media.ALBUM,
                MediaStore.Audio.Media.TRACK,
                MediaStore.Audio.Media.TITLE,
                MediaStore.Audio.Media.ARTIST,
                MediaStore.Audio.Media.DURATION
            )

            //MediaProviderからの抽出条件を設定
            //selectionは抽出条件、？は抽出条件のパラメータ。selectionArgsでパラメータを設定する。
            //下記の場合、IS_MUSICが0ではないものを抽出。カラムIS_MUSICは、Audio fileが音楽であれば
            //0以外になる。
            val selection = "${MediaStore.Audio.Media.IS_MUSIC} != ?"
            val selectionArgs = arrayOf("0")
            //ソート順の設定。 カラム"_ID"の昇順でソート。
            val sortOrder = "${MediaStore.Audio.Media._ID} ASC"

            //設定した条件でMediaProviderから条件に合うレコードを抽出する。
            context.contentResolver.query(
                MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                //データを取得するカラム
                projection,
                //データを抽出する条件
                selection,
                selectionArgs,
                //ソート順
                sortOrder
            )?.use{cursor ->
                //指定された名前のカラムが何列目か、インデックスを取得する。
                //_ID: ユニークID
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
                //IS_MUSIC: 音楽かどうかを示すフラグ。Audio fileが音楽であれば0以外。
                val isMusicColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.IS_MUSIC)
                //DISPLAY_NAME: ファイル名？
                val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
                //ALBUM: アルバム名
                val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
                //TRACK: アルバム中のトラック番号
                val trackColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
                //TITLE: 楽曲のタイトル
                val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
                //ARTIST: アーティスト
                val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
                //DURATION: 演奏時間
                val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)

                //抽出条件に合う全てのレコードのから楽曲の情報を取得して、端末内の音楽ファイルの楽曲情報のリストを作成する。
                while (cursor.moveToNext()) {
                    val id = cursor.getString(idColumn)
                    val isMusic = cursor.getString(isMusicColumn)
                    val displayName = cursor.getString(displayNameColumn)
                    val album = cursor.getString(albumColumn)
                    //取得した値(文字列)が数値として有効な表現ではない場合、track = "0"とする
                    val track = cursor.getString(trackColumn) ?: "0"
                    val title = cursor.getString(titleColumn)
                    val artist = cursor.getString(artistColumn)
                    val duration = cursor.getString(durationColumn)
                    val contentUri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,id.toLong())

                    //新たに抽出した楽曲の情報（１曲分）を、楽曲情報のリストaudioに追加する。
                    val tune = MediaStoreAudio(
                        id=id,
                        displayName=displayName,
                        isMusic=isMusic,
                        album=album,
                        track=track,
                        title=title,
                        artist=artist,
                        duration=duration,
                        contentUri=contentUri
                    )
                    audioList += tune

                    Log.v(TAG,"!!!! queryAudio !!!! Added tune: $tune")
                }
            }
        }

        Log.v(TAG,"!!!! queryAudio !!!! Found ${audioList.size} tunes")
        //端末内の音楽ファイルの楽曲情報のリストを返す。
        return audioList
    }

}

//取得した楽曲カタログ内の楽曲の情報の項目を、ExoPlayerで利用する楽曲情報の各項目にマッピングする関数。
//MediaStoregeから取得する楽曲の情報で、durationはミリ秒のため、オリジナルのような変換は不要。
//genreはMediaStoregeから取得する楽曲の情報に含まれないので、マッピングする項目から削除した。
fun MediaMetadataCompat.Builder.from(MediaStorageAudio: MediaStoreAudio): MediaMetadataCompat.Builder {

    id = MediaStorageAudio.id
    title = MediaStorageAudio.title
    artist = MediaStorageAudio.artist
    album = MediaStorageAudio.album
    duration = MediaStorageAudio.duration.toLong()
    mediaUri = MediaStorageAudio.contentUri.toString()
    albumArtUri = ""
    trackNumber = MediaStorageAudio.track.toLong()
    trackCount = 1L
    flag = MediaBrowserCompat.MediaItem.FLAG_PLAYABLE

    // To make things easier for *displaying* these, set the display properties as well.
    displayTitle = MediaStorageAudio.title
    displaySubtitle = MediaStorageAudio.artist
    displayDescription = MediaStorageAudio.displayName
    displayIconUri = ""

    // Add downloadStatus to force the creation of an "extras" bundle in the resulting
    // MediaMetadataCompat object. This is needed to send accurate metadata to the
    // media session during updates.
    downloadStatus = MediaDescriptionCompat.STATUS_NOT_DOWNLOADED

    // Allow it to be used in the typical builder style.
    return this
}

//MediaStoreから取得した楽曲の情報を格納するためのdata class。
data class MediaStoreAudio(
    val id: String = "",
    val displayName: String = "",
    val isMusic: String = "",
    val album: String = "",
    val track: String = "0",
    val title: String = "",
    val artist: String = "",
    val duration: String = "-1",
    val contentUri: Uri
)

private const val TAG = "MusicCatalog"
```

MusicService内のオリジナルコードでJsonSource.lounch()を呼び出してリモートサーバーから音楽情報のリストを取得している部分を、新たに作成したMusicSource.lounch()に置き換える。

```kotlin:MusicService.kt
open class MusicService : MediaBrowserServiceCompat() {

//途中省略

    //116行目あたり
    //ローカルファイル（端末内の音楽ファイル）を対象とするようコードを変更しているが、下記はオリジナルからの変更なし。
    //ドキュメントには、DefaultDataSourceFactoryはファイルではないURIデータソースのためのデータファクトリで
    //ある旨記載されているが、URIのスキームが "content://"のときは、これを使うらしい。
    //ファイルURIデータソースのためのデータファクトリFileDataSource.Factoryは、スキームが "file://"のとき
    //に使用する。
    private val dataSourceFactory: DefaultDataSourceFactory by lazy {
        DefaultDataSourceFactory(
            /* context= */ this,
            Util.getUserAgent(/* context= */ this, UAMP_USER_AGENT), /* listener= */
            null
        )
    }

    //途中省略

    //126行目あたり
    //リモートサーバーへはアクセスしないので、オリジナルコードのこの部分は不要。
    //private val remoteJsonSource: Uri =
    //    Uri.parse("https://storage.googleapis.com/uamp/catalog.json")

         //途中省略

         //211行目あたり
         //オリジナルコードのこの部分を、MediaStoreから楽曲情報のリストを取得するコードに置き換える。
         //mediaSource = JsonSource(source = remoteJsonSource)
         //serviceScope.launch {
         //    mediaSource.load()
         //}

        //MediaStoreから端末内の音楽ファイルの楽曲カタログを取得する。
        //MusicCatalogは、MediaStoreから楽曲カタログを取得し、プレイヤーで使用可能な形態で保持するクラス。
        //楽曲カタログの取得は、コルーチンで実施する。
        mediaSource = MusicCatalog(application)
        serviceScope.launch {
            mediaSource.load()
        }

        //以下省略

```


MediaStoreで端末内の音楽ファイルの情報を取得するためには、ストレージのリードアクセスの権限が必要。
権限を要求するために、Manifestを変更する。UAMPには、３つのAndroidManifest.xmlが存在するが、
/common/src/main/AndroidManifest.xml
を変更した。

```xml:AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.uamp.media">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <!--  ストレージのリードアクセスのパーミッションを要求するために下記を追加  -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    <!--  以下省略  -->

```

これで、アルバムメニューに端末内の音楽ファイルが表示され、再生が可能になる。

ただし、このままでは、ユーザーがAndroidの設定画面からアプリに対するストレージアクセスの権限を設定することが必要。
アプリ起動時にストレージアクセス権限がない場合は、権限の設定を行うためのダイアログを表示するようにする。
MainActivityで、アクティビティー生成時にストレージアクセスの権限を確認し、権限がなければ権限の付与を求めるダイアログを表示する。権限が得られなければ、アプリを終了する。
オリジナルのコードのonCreate()内の処理を関数化し、ストレージアクセス権限があればこの関数を実行するように変更する。また、権限がなければ、権限を要求するダイアログを表示し、権限が得られれば、onCreate()処理の関数を実行するようにする。

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

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

          //52行目あたり
          //オリジナルコードでのonCreate()内での処理を関数化するため、下記の部分を削除。
          //// Initialize the Cast context. This is required so that the media route button can be
          //// created in the AppBar
          //castContext = CastContext.getSharedInstance(this)
          //
          //setContentView(R.layout.activity_main)

          //途中省略

          //91行目あたり
          //viewModel.navigateToMediaItem.observe(this, Observer {
              //it?.getContentIfNotHandled()?.let { mediaId ->
                  //navigateToMediaItem(mediaId)
              //}
          //})

        //ストレージアクセス権限がなければ権限の付与を求めるダイアログを表示するため、下記を追加する。
        //ストレージアクセス権限を確認する。
        if (!haveStoragePermission()) {
            //ストレージアクセス権限がなければ、ユーザに権限の付与を求めるダイアログを表示。
            requestPermission()
        }else{
            //ストレージアクセス権があれば、オリジナルコードのonCreate()内の処理を実施。
            activityCreationProcess()
        }
    }

          //途中省略

    //134行目あたり
    private fun getBrowseFragment(mediaId: String): MediaItemFragment? {
        return supportFragmentManager.findFragmentByTag(mediaId) as MediaItemFragment?
    }


    //オリジナルコードのonCreate()で実施していた処理を行うための関数を新たに作成する。
    private fun activityCreationProcess():Unit{
        castContext = CastContext.getSharedInstance(this)

        setContentView(R.layout.activity_main)

        volumeControlStream = AudioManager.STREAM_MUSIC

        //MainActivityViewModelのnavigateToFragmentを監視し、変化があればFragmentを変更する。
        viewModel.navigateToFragment.observe(this, Observer {
            it?.getContentIfNotHandled()?.let { fragmentRequest ->
                //Fragmentの置き換え処理を行うために、FragmentManagerを呼び出す。
                val transaction = supportFragmentManager.beginTransaction()
                //Fragmentを置き換える。すでにFragmentが存在するときは、removeしてからaddする。
                transaction.replace(
                    R.id.fragmentContainer, fragmentRequest.fragment, fragmentRequest.tag
                )
                //backstackにFragmentを追加する。
                //戻るボタンでこの状態の画面に戻るようにする。
                if (fragmentRequest.backStack) transaction.addToBackStack(null)
                //上記Fragmentの置き換えを実行する。
                transaction.commit()
            }
        })

        //MainActivityViewModelのrootMediaIdを監視し、変化があればFragmentを生成する。
        //アプリが起動し、MediaBrowserServiceに接続すると、rootMediaIdが　nullから"/"に変化する。
        viewModel.rootMediaId.observe(this,
            Observer<String> { rootMediaId ->
                rootMediaId?.let { navigateToMediaItem(it) }
            })

        //MainActivityViewModelのnavigateToMediaItemを監視し、変化があればFragmentを更新する。
        //navigationToMediaItem()の処理で、変数navigateToFragmentが変更され、上で設定した
        //observerがこれを検知してFragmentの更新処理を行う。
        viewModel.navigateToMediaItem.observe(this, Observer {
            it?.getContentIfNotHandled()?.let { mediaId ->
                navigateToMediaItem(mediaId)
            }
        })
    }

    //ストレージアクセス権限がない場合に対応するため、４つの関数を追加する。
    //ストレージアクセス権限付与要求に対する応答に応じた処理をする関数
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            READ_EXTERNAL_STORAGE_REQUEST -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    //ストレージアクセス権限が付与（"ALLOW"を選択）されていれば、アクティビティー生成処理（オリジナルコードの
                    //onCreate()内で実施していた処理）を実行する。
                    activityCreationProcess()
                } else {
                    //ストレージアクセス権限付与されたかった（"DENY"を選択)ときは、詳細の情報を取得する。
                    val showRationale =
                        ActivityCompat.shouldShowRequestPermissionRationale(
                            this,
                            Manifest.permission.READ_EXTERNAL_STORAGE
                        )

                    if (showRationale) {
                        //"Don't ask again"はチェックされていないときは、アプリを終了する。
                        //   （再度アプリを起動したときに、ストレージアクセス権付与要求のダイアログが表示される。）
                        finish()
                    } else {
                        //"Don't ask again"が選択されたときは、Androidのアプリ設定画面を表示する。
                        goToSettings()
                    }
                }
                return
            }
        }
    }


    //Androidのアプリ情報表示画面を表示する関数
    private fun goToSettings() {
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")).apply {
            addCategory(Intent.CATEGORY_DEFAULT)
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }.also { intent ->
            startActivity(intent)
        }
    }


    //ストレージアクセス権限を確認する関数
    private fun haveStoragePermission() =
        ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.READ_EXTERNAL_STORAGE
        ) == PackageManager.PERMISSION_GRANTED


    //ストレージアクセス権限の付与を要求する関数。ダイアログを表示する。
    //ダイアログで要求に対する応答が入力されると、onRequestPermissionResult()が実行される。
    private fun requestPermission() {
        if (!haveStoragePermission()) {
            //ストレージ読み出し権限を要求する。
            //ファイルの追加や削除などの機能をアプリに追加するときは、
            //書き込み権限（Manifest.permission.WRITE_EXTERNAL_STORAGE）を追加する。
            val permissions = arrayOf(
                Manifest.permission.READ_EXTERNAL_STORAGE
            )
            ActivityCompat.requestPermissions(this, permissions, READ_EXTERNAL_STORAGE_REQUEST)
        }
    }

}

//WRITE_EXTERNAL_STORAGE権限のリクエストコード
private const val READ_EXTERNAL_STORAGE_REQUEST = 0x1045


private const val TAG = "MainActivity"

```

クライアント/サーバーアーキテクチャで、クライアント側であるMainActivityにストレージ権限取得の処理を置くことが適切かどうか自信がないが、とりあえずこれでユーザーに権限の付与を要求するダイアログが表示されるようになる。

![スクリーンショット 2021-08-20 18.32.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679257/c156b272-18ad-d25d-9537-39343b8f375a.png)




楽曲の抽出条件が MediaStoreから取得した楽曲の情報に適していないため、リコメンデッドメニューは機能しない。メニューのカスタマイズについては、別途投稿する予定。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679257/07da218e-9196-e07b-16d7-d050497daadc.png)

MediaStoreからの楽曲カタログの取得は、アクティビティーが生成されたときに一度実施されるだけなので、途中で端末内の音楽ファイルに変化があっても反映されない。
端末内の音楽ファイルが変更されたときにメニューを更新する方法については、改めて投稿する予定。



#参考
[Androidデベロッパー 共有ストレージからメディア ファイルにアクセスする](https://developer.android.com/training/data-storage/shared/media?hl=ja)
[google MediaStoreサンプルソースコード githubリポジトリ](https://github.com/android/storage-samples/tree/master/MediaStore)
[アンドロイド - MediaStoreでメディアファイルの情報を読む](https://codechacha.com/ja/android-mediastore-read-media-files/)
[Androidデベロッパー　Android での権限](https://developer.android.com/guide/topics/permissions/overview?hl=ja)

#関連する投稿#
[Universal Android Music Player(UAMP)のカスタマイズ （音源の変更　1/2）](https://qiita.com/Jin_Kubota/items/fc94ecd12bc069a58926)
[Universal Android Music Player(UAMP)のカスタマイズ （メニューの変更　1）](https://qiita.com/Jin_Kubota/items/382f41f42247219fbe5c)
[Universal Android Music Player(UAMP)のカスタマイズ （メニューの変更　2）](https://qiita.com/Jin_Kubota/items/7a7ba6de18eb6e1db8a7)

#おわりに
プログラミング初心者、AndroidアプリのコードをいじるのもKotlinを使うのも初めてなので、誤りが多々あると思います。アドバイス、励ましのコメントなどいただけると嬉しいです。