JobIntentServiceで行う端末の楽曲検索

現在、個人で音楽プレイヤーアプリを作っていて、
楽曲の一覧表示まで、できているのですが、

そのとき使った楽曲検索方法について、ご紹介したいと思います。

UIスレッドを妨げず、楽曲の検索処理を分離したかったので、
JobIntentServiceを使いました。

ちなみに、検索した楽曲を表示する部分については書いてないので、
そこはお好みでレイアウトを組んでいただければと思います。

## JobIntentServiceって何？

JobIntentServiceとは、IntentServiceの親戚のようなもので、
Intentに情報を詰め込んで、JobIntentServiceに渡すと、
バックグラウンドで、よしなに処理してくれるものです。

Oreo以降とそれ以前で、挙動が違うみたいですが、
今回の内容にはあまり関係ないので、詳しくは説明しません。

JobIntentServiceについて詳しく知りたい方は、[こちら](https://developer.android.com/reference/android/support/v4/app/JobIntentService.html)

使い方としては、こんな感じです。

まず、JobIntentServiceを継承したクラスを作ります。

```kotlin

class MyJobIntentService: JobIntentService() {

/** ユニークID */
private const val JOB_ID = 100

override fun onHandleWork(intent: Intent) {

  // ここにJobIntentServiceで行いたい処理を書く。
  }

}
```

かならず `onHandleWork` をOverrideします。
Intentを渡すとこのメソッドに処理が入ってきますので、
ここに、バックグラウンドで行いたい処理を記述します。

`JOB_ID`は、サービスを識別するユニークなIDです。
同じJobIntentServiceを呼び出すときには、同じjobidを使用するようにします。


次にIntentを生成して、JobIntentServiceのenqueueWorkを呼び出します。

```kotlin

val Intent = Intent(context, MyJobIntentService::class.java)
intent.putExtra("name", "value")

MyJobIntentService.enqueueWork(context, MyJobIntentService::class.java, JOB_ID, intent)
```

ここで渡したIntentが、onHandleWorkに渡されます。
今回は、onHandleWork内で、楽曲ファイルの検索を行います。

## 楽曲ファイルの検索には、ContentResolver

楽曲にかぎらず、端末内のファイルを取得するときには、
ContentResolverを使います。

ContentResolverは、Context経由で取得することができます。

ActivityやFragment以外でも呼び出せるように、
下記のようにApplicationクラスを作っておくと便利です。


```Kotlin

class MyApplication : Application() {

    companion object {

        private lateinit var sInstance: Application

        fun getInstance() = sInstance
    }

    override fun onCreate() {
        super.onCreate()

        sInstance = this
    }
}

```

```Kotlin
private val mResolver: ContentResolver = MyApplication .getInstance().contentResolver
```


ContentResolverが取得できたら、queryメソッドを使って検索を行います。

```Java
public final Cursor query (Uri uri, 
                String[] projection, 
                String selection, 
                String[] selectionArgs, 
                String sortOrder)
```

返り値であるCursorを操作して、情報を取得します。

各引数の説明は以下のとおりです。

### uri
取り出したいコンテンツの場所を表します。
楽曲だと、`content://media/external/audio/media` になります。

### projection
取得したいカラム名を指定します。
楽曲だと、タイトルやアーティスト名などです。

すべてのカラムを取得したいときは、nullを渡します。

### selection
検索条件を指定します。指定した検索条件に合致するものだけが抽出されます。
例 `"WHERE title = ?"`

### selectionArgs
selectionの ? が、指定したselectionArgsで置き換わります。

### sortOrder
並び替え条件です。
例 `"ORDER BY title"` 


使うクラスの説明は大体終わったので、
ここから検索ロジックを書いていきたいと思います。

## Cursorクラスの拡張関数を定義する

queryで返ってくるCursorクラスには、カラム名を指定して直接Valueを取るような関数が存在しません。

例えば、`title`というカラム名のValueを取得したければ、
まずgetColumnIndex(column : String)で、カラムのインデックスを取得し、
それをgetString(columnIndex : Int)に渡す必要があります。

これは非常に面倒なので、
CursorExt.ktファイルを作って、そこにCursorの拡張関数を定義します。

```kotlin:CursorExt.kt

/**
 * Cursorクラスの拡張関数群
 */

fun Cursor.getIntFromColumn(columnName: String) = this.getInt(this.getColumnIndex(columnName))
fun Cursor.getLongFromColumn(columnName: String) = this.getLong(this.getColumnIndex(columnName))
fun Cursor.getFloatFromColumn(columnName: String) = this.getFloat(this.getColumnIndex(columnName))
fun Cursor.getStringFromColumn(columnName: String) = this.getString(this.getColumnIndex(columnName))

```

こうすることで、カラム名を渡して直接Valueを取れるようになります。
扱う型を増やしたいときには、適宜、拡張関数を追加してください。
（例：getByteFromColumn()など）

## 楽曲のデータクラスを作る

取得した楽曲情報を格納するクラスを作成します。

イミュータブルなデータクラスを作る場合、
Javaだと、フィールドごとに、Setterを作らなくてはいけないので大変ですが、

Kotlinには、`data class` があるので簡単に書くことができます。


```kotlin:MusicMetadata.kt

/**
 * 楽曲メタデータ
 */
data class MusicMetadata(
    val mId: Long = -1, // ID
    val mArtist: String = "", // アーティスト名
    val mAlbum: String = "", // アルバム名
    val mAlbumId: Long = -1, // アルバムID
    val mAlbumKey: String = "", // アルバムキー
    val mDurationInMs: Long = -1, // 楽曲の再生時間
    val mTrackNumber: Int = -1, // アルバムの収録インデックス
    val mTitle: String = "", // 楽曲名
    val mFilePath: String = "" // ファイルパス
) 

```

内部でequals()やhashCode()といったメソッドが自動生成されるので、便利です。

## 定数クラスを作る

Intentに情報を詰めるとき、Intentから情報を引き出すときに使う、
定数を保持するクラスを作成します。

```kotlin:ContentResolverConst.kt
/**
 * MyJobIntentServiceに渡すIntentのキー群
 *
 * 例 :
 * intent = Intent()
 * intent.purString(QUERY_URI, queryUri)
 */
object ContentResolverConst {

    /** ContentResolverが管理しているuri */
    const val QUERY_URI = "queryUri"
    /** 取得したいカラム名 */
    const val QUERY_PROJECTION = "projection"
    /** 絞り込みたいカラム名 */
    const val QUERY_SELECTION = "selection"
    /** 指定したカラム名の条件 */
    const val QUERY_SELECTION_ARGS = "selectionArgs"
    /** ソートしたいカラム名 */
    const val QUERY_SORT_ORDER = "sortOrder"

    /**
     * リストに追加するか、上書きするかを指定するフラグ
     * 追加 True : 上書き False
     */
    const val APPEND_FLAG = "appendFlag"


    /** 検索の種別 */
    const val SEARCH_ACTION_KEY = "actionKey"

    /** 楽曲検索時 */
    const val ACTION_SEARCH_MUSIC = "searchMusic"
}
```


## JobIntentServiceを作る


JobIntentServiceクラスを継承した、MyIntentServiceを作成します。

```kotlin:MyJobIntentService.kt

class MyJobIntentService: JobIntentService() {

/** ユニークID */
private const val JOB_ID = 100
/** 楽曲の検索に使うURI */
private val TRACK_EXTERNAL_URI: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI

/** 楽曲を検索する際に使用するカラム */
private val TRACK_COLUMNS: Array<String> = arrayOf(
   MediaStore.Audio.Media._ID, // シーケンスID
   MediaStore.Audio.Media.ARTIST, // アーティスト名
   MediaStore.Audio.Media.ALBUM, // アルバム名
   MediaStore.Audio.Media.ALBUM_ID, // アルバムID
   MediaStore.Audio.Media.ALBUM_KEY, // アルバムキー。アルバムの検索に使用する
   MediaStore.Audio.Media.DURATION, // 楽曲の再生時間
   MediaStore.Audio.Media.TRACK, // 楽曲のアルバム内でのトラックナンバー
   MediaStore.Audio.Media.TITLE, // 楽曲名
   MediaStore.Audio.Media.DATA // ファイルパス
   )

/** MediaStoreへのアクセッサ */
private val mResolver: ContentResolver = MyApplication.getInstance().contentResolver
/** アプリケーションコンテキスト */
private val mContext: Context = MyApplication.getInstance().applicationContext

override fun onHandleWork(intent: Intent) {

  }

}

```

クラスを作成したら、AndroidManifest.xmlに記述を追加します。

```xml:AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="...">

    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

    <application
            android:name=".MyApplication"
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:roundIcon="@mipmap/ic_launcher_round"
            android:supportsRtl="true"
            android:theme="@style/AppThemeNoActionBar">

        ~ 中略 ~

        <service
                android:name="MyJobIntentService"
                android:permission="android.permission.BIND_JOB_SERVICE"
                android:exported="true"/>

    </application>

</manifest>
```

そしたら、MyJobIntenetService内に、Intentを生成する
createIntentメソッドを実装します。


```kotlin:MyJobIntentService.kt
    /**
     * Intentを作成する
     * 作成したIntentは、onHandleIntent内で使用される
     *
     * @param queryUri  ContentResolverが管理しているuri
     * @param projection 取得したいカラム名
     * @param selection 絞り込みたいカラム名
     * @param selectionArgs 指定したカラム名の条件
     * @param sortOrder ソートしたいカラム名
     * @param appendFlag リストに追加するか、上書きするかを指定するフラグ ⇒ 追加 True : 上書き False
     */
    private fun createIntent(
        searchAction: String,
        queryUri: Uri,
        projection: Array<String>?,
        selection: String?,
        selectionArgs: Array<String>?,
        sortOrder: String?,
        appendFlag: Boolean
    ): Intent {

        return Intent(mContext, MyJobIntentService::class.java).apply {
            putExtra(ContentResolverConst.SEARCH_ACTION_KEY, searchAction)
            putExtra(ContentResolverConst.QUERY_URI, queryUri)
            putExtra(ContentResolverConst.QUERY_PROJECTION, projection)
            putExtra(ContentResolverConst.QUERY_SELECTION, selection)
            putExtra(ContentResolverConst.QUERY_SELECTION_ARGS, selectionArgs)
            putExtra(ContentResolverConst.QUERY_SORT_ORDER, sortOrder)
            putExtra(ContentResolverConst.APPEND_FLAG, appendFlag)
        }
    }

```

渡された情報をIntentに詰めるだけです。

次は、外から呼ばれる楽曲検索のトリガーとなるメソッドを実装します。

```kotlin:MyJobIntentService.kt

    /**
     * SDカードと端末内部に保存されている、全ての楽曲を取得する
     */
    fun searchAllMusic(context: Context) {
        searchAllMusicFromUri(context, TRACK_EXTERNAL_URI, false)
    }

    /**
     * Uriから楽曲を取得する
     *
     * @param queryUri 検索するフォルダパスを表すURI
     * @param appendFlag リストに追加するか、上書きするかを指定するフラグ
     */
    private fun searchAllMusicFromUri(context: Context, queryUri: Uri, appendFlag: Boolean) {
        val intent: Intent = createIntent(
            searchAction = ContentResolverConst.ACTION_SEARCH_MUSIC,
            queryUri = queryUri,
            projection = TRACK_COLUMNS,
            selection = null,
            selectionArgs = null,
            sortOrder = MediaStore.Audio.Media.TITLE,
            appendFlag = appendFlag
        )
        enqueueWork(context, ContentResolverService::class.java, JOB_ID, intent)
    }
```

最後に、onHandleWork内に処理を書いていきます。

```kotlin:MyJobIntentService.kt

    override fun onHandleWork(intent: Intent) {

        val searchAction: String = intent.getStringExtra(ContentResolverConst.SEARCH_ACTION_KEY)
        val appendFlag: Boolean = intent.getBooleanExtra(ContentResolverConst.APPEND_FLAG, false)

        val queryUri: Uri = intent.getParcelableExtra(ContentResolverConst.QUERY_URI)
        val projection: Array<String>? = intent.getStringArrayExtra(ContentResolverConst.QUERY_PROJECTION)
        val selection: String? = intent.getStringExtra(ContentResolverConst.QUERY_SELECTION)
        val selectionArgs: Array<String>? = intent.getStringArrayExtra(ContentResolverConst.QUERY_SELECTION_ARGS)
        val sortOrder: String? = intent.getStringExtra(ContentResolverConst.QUERY_SORT_ORDER)

        val cursor: Cursor = mResolver.query(queryUri, projection, selection, selectionArgs, sortOrder)

        if (!cursor.moveToFirst()) {
            // 最初の行に、カーソルを動かせない場合
            cursor.close()
            return
        }

        // 楽曲リストを取得する場合
        val musicList: List<MusicMetadata> = createMusicMetadataFromCursor(cursor)

        cursor.close()
    }

    /**
     * 検索結果を楽曲メタデータに格納する
     * cursorのmoveToFirstが呼び出されていることを、前提にしている
     */
    private fun createMusicMetadataFromCursor(cursor: Cursor): List<MusicMetadata> =

        mutableListOf<MusicMetadata>().apply {
            while (cursor.moveToNext()) {
                add(
                    MusicMetadata(
                        mId = cursor.getLongFromColumn(MediaStore.Audio.Media._ID),
                        mArtist = cursor.getStringFromColumn(MediaStore.Audio.Media.ARTIST),
                        mAlbum = cursor.getStringFromColumn(MediaStore.Audio.Media.ALBUM),
                        mAlbumId = cursor.getLongFromColumn(MediaStore.Audio.Media.ALBUM_ID),
                        mAlbumKey = cursor.getStringFromColumn(MediaStore.Audio.Media.ALBUM_KEY),
                        mDurationInMs = cursor.getLongFromColumn(MediaStore.Audio.Media.DURATION),
                        mTrackNumber = cursor.getIntFromColumn(MediaStore.Audio.Media.TRACK),
                        mTitle = cursor.getStringFromColumn(MediaStore.Audio.Media.TITLE),
                        mFilePath = cursor.getStringFromColumn(MediaStore.Audio.Media.DATA)
                    )
                )
            }
        }
```

これで楽曲の検索結果が、リストに格納されました。

あとはこのリストを、RecyclerViewなどに入れ込めば、
楽曲リストの表示が行なえます。

ここまでお読み頂きありがとうございました。