🏛️

ローカルのHTMLをGradleタスクでstrings.xmlに変換してJetpack composeで表示する

2024/12/23に公開

なぜそんなことするの?

利用規約など、一部を装飾する場合が多いテキストを都度 Spannable で装飾するのは面倒だから。
あとは、個人開発をしている場合は利用規約をホスティングするのも、やや手間だなっていう短絡的思考です。

メリット・デメリット

メリット

  • 都度 Spannable で装飾しなくていい。
  • WebView で表示するよりライト/ダークのアプリテーマを適応させやすい。
  • Android 特有の Xml エスケープをしなくていい。

デメリット

  • そもそもそんなことしている案件ある?
    • 利用規約が更新されるたびに同意してもらうような殊勝な案件なら。。。(IoT系なら稀にある)
  • Build する度に Html -> Xml のタスクを実行しているので、デカいファイルとかだと少し処理速度気になるかも(実装次第だし、そんな気にならないとは思うけど)
  • デザイン性がやや犠牲になる
    • <li>タグ<ol>タグがまだ利用できない。(ui-text 1.7.3 でもまだ使えない)
    • タグごとに padding / margin やフォント、背景色などの細かい設定は style 属性で行う必要がありそう。

成果物

https://github.com/blue928sky/SampleTerm

やったこと

環境

  • Android Studio Android Studio Ladybug | 2024.2.1 Patch 3
  • Kotlin 2.1.0
  • Gradle 8.11.1
  • Jetpack compose BoM 2024.12.01
    • androidx.compose.ui:ui-text 1.7.0+ から使用可能。

https://android-developers.googleblog.com/2024/05/whats-new-in-jetpack-compose-at-io-24.html#:~:text=AnnotatedString.fromHtml()

Gradleタスク

app/build.gradle.kts
import com.android.build.gradle.tasks.MergeResources
import org.jdom2.Document
import org.jdom2.Element
import org.jdom2.output.Format
import org.jdom2.output.XMLOutputter

android {
    // ...

    // 生成した strings.xml を認識させる
    sourceSets {
        named("main") {
            val buildDir = layout.buildDirectory.get().asFile
            res.srcDirs(buildDir.resolve("generated/custom/res"))
        }
    }
}

// リソースが生成されてマージされる前に実行するように依存を持たせる
tasks.withType<MergeResources> {
    dependsOn.add("generateTermTaskName")
}

// term.html を strings.xml に変換するタスクを追加
tasks.register("generateTermTaskName") {
    val stringId = "term_content"   // R.string.term_content
    val termFile = File(project.projectDir, "term.html")
    val outputDir = File(layout.buildDirectory.get().asFile, "generated/custom/res/values") // build/generated/custom/res/values/ 配下に生成

    doFirst {
        outputDir.mkdirs()

        val termHtml = termFile.readText()
        val term = termHtml
            // 改行削除
            .replace("""[\n\r]""".toRegex(), "")
            // コメント削除
            .replace("""<!--.+?-->""".toRegex(), "")
            // <body> タグ内のみ抽出
            .replace(""".*<body>(.+?)</body>.*""".toRegex(), "$1")

        val resourcesElement = Element("resources")
        val termContentElement = Element("string").setAttribute("name", stringId).setText(term)
        resourcesElement.addContent(termContentElement)

        val document = Document(resourcesElement)
        val outputter = XMLOutputter(Format.getPrettyFormat())

        File(outputDir, "strings.xml").writeText(outputter.outputString(document))
    }
}

このタスクの半分くらいは Gemini に聞きました。
Goovy を使用していたのでうまく動かなかった部分と、Gradle の configuration cache に対応したとこだけ書いたって感じですね。

生成されたもの

build/generated/custom/res/values/string.xml
<?xml version="1.0" encoding="UTF-8"?>
<resources>
    <string name="term_content">&lt;p&gt;○○○(以下「当社」といいます。)は、当社が運営するスマートフォン用アプリ(以下「本アプリ」といいます。)で提供するサービス(以下「本サービス」といいます。)をお客様にご利用いただくことに関し、ご利用規約(以下、「本規約」といいます。)を定めております。&lt;/p&gt;&lt;br&gt;&lt;h2&gt;第1条(本規約の適用)&lt;/h2&gt;&lt;p&gt;1. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;&lt;p&gt;2. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;&lt;br&gt;&lt;h2&gt;第2条(利用規約の改定など)&lt;/h2&gt;&lt;p&gt;1. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;&lt;p&gt;2. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;&lt;p&gt;3. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;&lt;br&gt;&lt;h2&gt;第3条(本アプリの利用)&lt;/h2&gt;&lt;p&gt;1. ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。ここに規約が入ります。&lt;/p&gt;</string>
</resources>

Xml のエンコードしなくていいのとても嬉しい。
(案件では両 OS 共通で文言を管理するシートを使用したりするので、iOS ではエンコード不要なのに! Android はエンコードしなとおかしくなるのに! みたいなことがまあまあ起こる)

Jetpack compose

TermScreen.kt
// ...
Text(
    text = AnnotatedString.fromHtml(htmlString = stringResource(id = R.string.term_content)),
    fontSize = 14.sp,
)
// ...

Jetpack compose は ui-text が 1.7.0+ であれば使用できます。

Note that bullet lists are not yet available.

と書いているので、対応はするつもりはあるのかも。

https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString.Companion#(androidx.compose.ui.text.AnnotatedString.Companion).fromHtml(kotlin.String,androidx.compose.ui.text.TextLinkStyles,androidx.compose.ui.text.LinkInteractionListener)

実際に表示

Light Dark
light dark

所感

個人でアプリ実装してるくらいならこれくらいの実装でいいや。

参考

Discussion