【Android] Jetpack Compose を使って OSSライセンス一覧を表示する

## はじめに

公式の [Google Play 開発者サービス](https://developers.google.com/android/guides/overview?hl=ja) API である `oss-licenses` プラグインを使うと以下のような OSS ライセンス一覧を表示することができる。(詳細は[ここ](https://developers.google.com/android/guides/opensource?hl=ja#display-license-info)を参照。)
<img src="https://developers.google.com/static/android/guides/images/oss-licenses.svg" alt="公式の OSS ライセンス表示" width="200">

同プラグインは build.gradle(.kts)の `dependencies` セクションに追加したライブラリを収集してくれるのだが、`oss-licenses` プラグインの `Activity` 上に表示され、本体アプリとデザインの統一性がなくのでできるなら使用を避けたい。

これまでは、サードパーティー製のライブラリとしてクックパッドさんの [License Tools Plugin for Android](https://github.com/cookpad/LicenseToolsPlugin) にお世話になっていたのだが残念ながらすでにアーカイブされている。他にこれといったサードパーティー製のライブラリが無いようなので、自分でコーディングすることにした。尚、UI には Jetpack Compose を用いる。

コーディングにあたっては「[Google OSS License Gradle Pluginのデータをパースする](https://zenn.dev/chibatching/articles/google-license-data-parse)」を参考にさせていただいた。同記事でも全然問題なく OSS ライセンス一覧を作れるのだが、途中 [Okio](https://github.com/square/okio) ライブラリを使っているところがあって、その導入方法の記載がなく、戸惑う人がいるだろうというのと、なるべく Android 公式の API で実装したい人もいるだろう、加えて私のように UI も参考にしたいという人向けに本記事を書いた。

## oss-licenses プラグインを導入

OSS ライセンス一覧のデータを作成すにるあたり、 [Google Play 開発者サービス](https://developers.google.com/android/guides/overview?hl=ja)の `oss-licenses` プラグインを使用する。

``` kotlin:build.gradle.kts
buildscript {
  repositories {
    ...
    google()
  }
  dependencies {
    ...
    classpath("com.google.android.gms:oss-licenses-plugin:0.10.6")
  }
}
```

``` kotlin:app/build.gradle.kts
plugins {
    id("com.android.application")
    id("com.google.android.gms.oss-licenses-plugin")
}
```

## OSS ライセンス一覧のデータを作成

Android プロジェクトをビルドすると `third_party_license_metadata` と `third_party_licenses` が生成される。 debug ビルドと release ビルドでそれぞれ生成されるところも抑えておく。

![スクリーンショット 2024-01-21 22.38.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/70413df9-05d7-0b45-ec75-897474e445c5.png)

build/ にファイルが生成されるが、 わざわざ src/main/res/raw/ にコピーしなくても良い。この状態で src/main/res/raw/ に存在するファイル同様に `R.raw.third_party_license_metadata` 、`R.raw.third_party_licenses` としてソースコードからアクセスすることができる。

## OSS ライセンス一覧のデータ

`third_party_license_metadata` ファイルと `third_party_licenses` ファイルの中身は例えば以下のようになっている。

```Text:third_party_license_metadata
0:46 Android Support Library Annotations
0:46 Android Support Library Custom View
0:46 Android Navigation Fragment
・・・
47:47 play-services-oss-licenses
95:1096 Animal Sniffer
・・・
```

```Text:third_party_licenses
http://www.apache.org/licenses/LICENSE-2.0.txt
https://developer.android.com/studio/terms.html
The MIT License

Copyright (c) 2008 Kohsuke Kawaguchi and codehaus.org.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
・・・
```

`third_party_license_metadata` ファイルには「offset」「length」「ライブラリ名」、`third_party_licenses` ファイルには「ライセンス条文」が入っている。

## OSS ライセンス一覧のデータをパース

`third_party_license_metadata` ファイルの「offset」「length」を使えば`third_party_licenses` ファイルからライセンス条文を取得できるようになっている。例えば、

```Text:third_party_license_metadata
95:1096 Animal Sniffer
```
は、 「Animal Sniffer」というライブラリのライセンス条文は `third_party_licenses` ファイルの offset= 95、length=1096 に格納されているという意味となる。
`third_party_license_metadata` ファイルと`third_party_licenses` ファイルをパースし、 OSS ライセンス一覧データを生成するコードは以下となる。

```kotlin: LibraryLicenseList
import android.content.Context
import android.util.Log
import dev.seabat.android.usbdebugswitch.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader

data class LibraryLicenseList(val licenseList: List<LibraryLicense>) : List<LibraryLicense> by licenseList {
    companion object {
        suspend fun create(context: Context): LibraryLicenseList {
            val licenses = loadLibraries(context).map {
                LibraryLicense(it.name, loadLicense(context, it))
            }
            return LibraryLicenseList(licenses)
        }

        private suspend fun loadLibraries(context: Context): List<Library> {
            return withContext(Dispatchers.IO) {
                val inputSteam = context.resources.openRawResource(R.raw.third_party_license_metadata)
                inputSteam.use { inputSteam ->
                    val reader = BufferedReader(InputStreamReader(inputSteam, "UTF-8"))
                    reader.use { bufferedReader ->
                        val libraries = mutableListOf<Library>()
                        while (true) {
                            val line = bufferedReader.readLine() ?: break
                            val (position, name) = line.split(' ', limit = 2)
                            val (offset, length) = position.split(':').map { it.toInt() }
                            libraries.add(Library(name, offset, length))
                        }
                        libraries.toList()
                    }
                }
            }
        }

        private suspend fun loadLicense(context: Context, library: Library): String {
            Log.d("LicenseList", "${library.name} ${library.offset} ${library.length}")
            return withContext(Dispatchers.IO) {
                val charArray = CharArray(library.length)
                val inputStream = context.resources.openRawResource(R.raw.third_party_licenses)
                inputStream.use { stream ->
                    val bufferedReader = BufferedReader(InputStreamReader(stream, "UTF-8"))
                    bufferedReader.use { reader ->
                        reader.skip(library.offset.toLong())
                        reader.read(charArray, 0, library.length)
                    }
                }
                String(charArray)
            }
        }
    }
}

data class Library(
    val name: String,
    val offset: Int,
    val length: Int,
)

data class LibraryLicense(
    val name: String,
    val terms: String //条文
)
```

`LibraryLicenseList#create` はファイル I/O を伴う少し重い処理のため、非同期で実行されるよう `suspend` メソッドとなっている。よって、 以下のように Coroutine ブロックから呼びだす。

```kotlin:LicensesViewModel.kt
class LicenseFragment : Fragment() {
    private val _licensesStateFlow = MutableStateFlow(LibraryLicenseList(arrayListOf()))
    private val licensesStateFlow = _licensesStateFlow.asStateFlow()
・・・    
    fun createLicenses() {
        lifecycleScope.launch {
            _licensesStateFlow.update {
                LibraryLicenseList.create(requireContext())
            }
        }
    }
・・・
```

## コンポーザブル関数でOSSライセンス一覧を表示する

ここまででOSS ライセンス一覧のデータを `StateFlow<LibraryLicenseList>` オブジェクトとして生成できているのであとはコンポーザブル関数で UI 表示だけである。

```kotlin:LicenseContent.kt
@Composable
fun LicenseContent(
    modifier: Modifier = Modifier,
    licensesStateFlow: StateFlow<LibraryLicenseList>
) {
    val licensesState by licensesStateFlow.collectAsState()
    LazyColumn(modifier = modifier) {
        items(licensesState) {
            Column() {
                Text(text = it.name, style = MaterialTheme.typography.titleMedium)
                Spacer(modifier = Modifier.height(8.dp))
                Text(text = it.terms, fontSize = 8.sp)
                Spacer(modifier = Modifier.height(24.dp))
            }
        }
    }
}
```

![スクリーンショット 2024-01-21 23.27.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/3ececdcd-5c8a-34a8-aa20-d9ce85b2ddf3.png)

## 補足

`oss-licenses` プラグインを導入すると、同プラグインの `AndroidManifest.xml` が本体アプリの `AndroidManifest.xml` にマージされる。別に悪さはしないだろうが後で存在を知って驚かなよう覚えておくよい。

```xml
        <activity
            android:label="@ref/0x7f0f006e"
            android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity" />

        <activity
            android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" />

        <activity
            android:theme="@ref/0x01030010"
            android:name="com.google.android.gms.common.api.GoogleApiActivity"
            android:exported="false" />
```            

## 参考

https://developers.google.com/android/guides/opensource?hl=ja

https://zenn.dev/chibatching/articles/google-license-data-parse