【Android】WorkManager でファイルのダウンロードの進捗を監視する

MIXI DEVELOPERS Advent Calendar 2023 24日目の記事です

https://qiita.com/advent-calendar/2023/mixi

WorkManager の [Long-running worker](https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/long-running) を使用すると、時間のかかるタスクをバックグラウンドで実行できます。

数年前までは[フォアグラウンドサービス](https://developer.android.com/develop/background-work/services/foreground-services?hl=en)を自力で実装する方法が一般的でしたが、現在では WorkManager を使って[進捗状況の監視](https://developer.android.com/topic/libraries/architecture/workmanager/how-to/intermediate-progress)や[処理の制約](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#work-constraints)などの高度な要件にも簡単に対応できるようになっています。

この記事では WorkManager でファイルのダウンロード処理を実装する例を紹介します。
ダウンロードの進捗を UI に表示したり、ネットワークに接続されるまで処理を延期させたりする方法についても紹介します。

# WorkManager を導入する

まずは WorkManager の依存関係を追加します。
[リリースノート](https://developer.android.com/jetpack/androidx/releases/work?hl=en)で最新のバージョンを確認してください。
現時点 (2023/12/24) の最新バージョンは v2.9.0 です。

```build.gradle.kts
dependencies {
    ...
    implementation("androidx.work:work-runtime:x.y.z")
}
```

Android 14 では[フォアグラウンドサービスタイプの宣言が必須](https://developer.android.com/about/versions/14/changes/fgs-types-required)になりました。
以下のようにして AndroidManifest にサービスタイプを宣言すると、Long-running worker が内部的にフォアグラウンドサービスを起動できるようになります。
ついでにファイルのダウンロードと通知の表示に必要なパーミッションも宣言しておきます。

```AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application>
        ...
        <service
            android:name="androidx.work.impl.foreground.SystemForegroundService"
            android:foregroundServiceType="dataSync"
            tools:node="merge" />
    </application>

</manifest>
```

`dataSync` のサービスタイプは将来的に非推奨になるため、今後のバージョンでは別の API に置き換えられる可能性があります。
バックグラウンドタスク関連の API は頻繁に仕様や制限が変わるため、今後も注視していくことになりそうです。

https://developer.android.com/about/versions/14/changes/fgs-types-required#data-sync

> 注: Android の将来のバージョンでは、このフォアグラウンド サービス タイプは非推奨になります。WorkManager またはユーザー開始型データ転送ジョブを使用するように移行することをおすすめします。

# Worker を作成する

ダウンロード用の Worker を作成します。
実際のダウンロード処理の実装については後述するとして、まずは実行中の通知を表示できることを確認します。

```kotlin
class DownloadWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
    override suspend fun doWork(): Result {
        createNotificationChannel() // 注1

        val url = inputData.getString(INPUT_URL) ?: return Result.failure()
        val fileName = Uri.parse(url).lastPathSegment ?: return Result.failure()
        val file = File(appContext.filesDir, fileName)

        download(file = file, url = url, onProgress = ::notifyProgress)
        return Result.success()
    }

    private suspend fun download(
        file: File,
        url: String,
        onProgress: suspend (Float) -> Unit
    ) = withContext(Dispatchers.IO) {
        // TODO download
        repeat(100) {
            onProgress(it / 100f)
            delay(50)
        }
    }

    private suspend fun notifyProgress(progress: Float) {
        // UI に progress を表示するため (後述)
        setProgress(workDataOf(PROGRESS to progress))

        // 通知を表示
        val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_downloading)
            .setContentTitle(appContext.getString(R.string.downloading))
            .setProgress(MAX_PROGRESS, (progress * MAX_PROGRESS).toInt(), false)
            .build()

        val foregroundInfo = ForegroundInfo(
            NOTIFICATION_ID,
            notification,
            FOREGROUND_SERVICE_TYPE_DATA_SYNC // 注2
        )
        setForeground(foregroundInfo)
    }

    private fun createNotificationChannel() {
        val notificationManager = appContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, appContext.getString(R.string.download), IMPORTANCE_DEFAULT)
        notificationManager.createNotificationChannel(channel)
    }

    companion object {
        const val PROGRESS = "progress"
        private const val NOTIFICATION_CHANNEL_ID = "notification_channel_id"
        private const val NOTIFICATION_ID = 0
        private const val INPUT_URL = "input_url"
        private const val MAX_PROGRESS = 100

        fun createDownloadRequest(url: String) = OneTimeWorkRequestBuilder<DownloadWorker>()
            .setInputData(workDataOf(INPUT_URL to url))
            .build()
    }
}
```

:::note
注1: NotificationChannel の作成は Android 8.0 以降で必要です。
:::

:::note
注2: フォアグラウンドサービスタイプの指定は Android 10 以降で利用可能です。
:::

このように `CoroutineWorker` を継承した Worker を作成すると、`doWork()` の中で suspend 関数を実行できます。

# Worker を利用する

画面上のダウンロードボタンを押したときに Worker を起動させる例を紹介します。
この例ではダウンロードするファイルの URL をテキストフィールドで入力できるようにしています。

```kotlin
class DownloadActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Column {
                    var url by remember { mutableStateOf("") }

                    TextField(
                        value = url,
                        onValueChange = { url = it },
                        modifier = Modifier.fillMaxWidth()
                    )
                    Button(onClick = {
                        // 注3
                        val request = DownloadWorker.createDownloadRequest(url)
                        val workManager = WorkManager.getInstance(applicationContext)
                        workManager.enqueue(request)
                    }) {
                        Text(text = stringResource(id = R.string.download))
                    }
                }
            }
        }
    }
}
```

:::note
注3:
Long-running worker の実行中には通知が表示されますが、Android 13 以降では[通知表示用のランタイムパーミッションが必要](https://developer.android.com/develop/ui/views/notifications/notification-permission?hl=en)です。
Jetpack Compose でアプリを構築している場合は [Accompanist](https://google.github.io/accompanist/permissions/) を使ってパーミッションを取得すると良いでしょう (この記事では具体的な実装を割愛します) 。
:::

# ダウンロード処理を実装する

DownloadWorker の `download()` 関数の中身を実装します。
ここでは Http クライアントとして [Ktor](https://ktor.io/) を使うことにします。

```DownloadWorker.kt
private suspend fun download(
    file: File,
    url: String,
    onProgress: suspend (Float) -> Unit
) = withContext(Dispatchers.IO) {
    onProgress(0f)
    val response = HttpClient().get(url) {
        onDownload { downloadedBytes, totalBytes ->
            launch {
                onProgress(downloadedBytes.toFloat() / totalBytes)
            }
        }
    }
    val body: ByteArray = response.body()
    file.writeBytes(body)
}
```

この例では コールバック引数 `onProgress` を渡すように実装していますが、進捗状況を出力する Kotlin Flow を返すようにしても良いでしょう。

# キャンセルやエラーをハンドリングする

ダウンロード中の通知にキャンセルボタンを追加し、Worker をキャンセルできるようにします。

```DownloadWorker.kt
private suspend fun notifyProgress(progress: Float) {
    ...
    val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID)
        ...
        .addAction(
            R.drawable.ic_cancel,
            appContext.getString(android.R.string.cancel),
            WorkManager.getInstance(appContext).createCancelPendingIntent(id)
        )
        .build()
    ...
}
```

キャンセルボタンが押されると `doWork()` のコルーチンの中で `CancellationException` が発生します。
これをキャッチしてファイルの削除やユーザーへの通知などを行うと良いでしょう。
また、`IOException` をキャッチしてダウンロードのエラーも適切にハンドリングします。

```DownloadWorker.kt
return try {
    download(...)
    Result.success()
} catch (error: IOException) {
    // ダウンロードのエラーが発生した
    file.delete()
    Result.failure()
} catch (error: CancellationException) {
    // Worker がキャンセルされた
    file.delete()
    Result.success()
}
```

# 進捗状況を UI から監視する

ダウンロードの進捗状況を UI から監視し、`LinearProgressIndicator` として表示できるようにします。
先月リリースされた WorkManager v2.9.0 では Worker の情報を Flow として取得する API が追加されたため、それを使ってみます。

```DownloadActivity.kt
Column {
    var url by remember { mutableStateOf("") }
    val coroutineScope = rememberCoroutineScope()
    var progress by remember { mutableFloatStateOf(0f) }

    TextField(value = url, onValueChange = { url = it })
    LinearProgressIndicator(
        progress = progress,
        modifier = Modifier.fillMaxWidth()
    )
    Button(onClick = {
        val request = DownloadWorker.createDownloadRequest(url)
        val workManager = WorkManager.getInstance(applicationContext)
        coroutineScope.launch {
            workManager.getWorkInfoByIdFlow(request.id)
                .collect { workInfo ->
                    progress = workInfo.progress.getFloat(DownloadWorker.PROGRESS, 0f)
                }
        }
        workManager.enqueue(request)
    }) {
        Text(text = stringResource(id = R.string.download))
    }
}
```

# ネットワークやストレージの制約を追加する

特定の条件を満たすまで Worker の処理を延期させたい場合は[制約](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#work-constraints)を追加します。

```DownloadWorker.kt
fun createDownloadRequest(url: String) = OneTimeWorkRequestBuilder<DownloadWorker>()
    .setInputData(workDataOf(INPUT_URL to url))
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresStorageNotLow(true)
            .build()
    )
    .build()
```

このようにすると、ネットワークに接続されていてストレージの空き容量があるときのみ Worker を実行させることができます。

# まとめ

この記事ではファイルのダウンロードを例にして WorkManager の使用方法を紹介しました。
ファイルのアップロードやローカルでのファイル変換など、その他の時間のかかる処理も同様の方法で実装できます。

私が業務で携わっているプロジェクトでは、自前で実装したフォアグラウンドサービスを WorkManager に移行したことでコードを非常にシンプルな形にリファクタリングできました。
リリース後も特に新しいクラッシュや不具合報告などが出ておらず、安定して動作しています。

WorkManager を活用したい方の参考になれば幸いです。