個人開発のAndroidアプリをF-Droidに登録申請したところ、レビューで Reproducible Builds への対応を求められました。
対応そのものは、最終的には設定2行の変更で済みました。ただしそこに至るまでに詰まった2箇所が、どちらもアプリのソースコードとは無関係でした。片方はAPKに自動で埋め込まれるビルドメタデータ、もう片方は設定ファイルの先頭3バイトです。
同じところで止まる人がいそうなので、実際に何が起きていたかを記録として残します。
何を確認しようとしていたか
F-Droidのreproducible buildは、ざっくり言えば「公開されているAPKと、F-Droid側が同じソースからビルドし直したAPKが一致すること」を確認する仕組みです。F-Droid公式ドキュメントでは、検証が成立する条件を次のように説明しています。
the APKs need to be completely identical before and after signing (apart from the signature itself)
署名を除いた部分が完全に一致している必要がある、ということです。裏を返すと、ビルドのたびに変わる値がAPKの中に1バイトでも入っていると成立しません。同ドキュメントは、崩れる原因として最も多いのは埋め込まれたタイムスタンプだと述べています。
つまりこちら側の作業は「ビルドごとに変わるものをAPKから追い出す」ことになります。
環境は次のとおりです。
| 項目 | 値 |
|---|---|
| Android Gradle Plugin | 9.0.0 |
| Gradle | 9.4.1 |
| JDK | 21 |
| Kotlin | 2.2.10 |
| compileSdk / minSdk | 36 / 26 |
なお申請の準備段階で、Gradleデーモン用のJDKを api.foojay.io から自動取得する設定ファイルも削除しています。F-Droidのビルドサーバーに不要なネットワーク経路を持ち込まないためで、こちらはビルド結果に影響しませんでした。本題は次の2つです。
罠1: AGPがrelease buildにgit revisionを埋め込む
APKのどこが変わっていたか
AGPには、ビルドしたコミットの情報をAPKに埋め込む機能があります。埋め込み先は次のパスです。
META-INF/version-control-info.textproto
これはApp Quality Insights(Android Studioがクラッシュのスタックトレースから該当バージョンのコードへ辿るための機能)向けのもので、中身はgitのリビジョン情報です。
問題は既定値でした。Android公式ドキュメントには、debugビルドで使いたい場合は明示的に有効化する、という文脈で次のように書かれています。
Release (non-debuggable) builds have the flag enabled by default.
releaseビルドでは既定で有効、つまり何も書かなくても入ります。
結果として、同じソースであってもコミットやチェックアウトが違えばAPKのこのファイルの中身が変わります。F-Droid側は特定のコミットにpinしてビルドし直すため、その状態では公開済みAPKとバイト単位で一致しません。ソースは同一なのに一致しない、という状況の原因がここでした。
対応
モジュールの build.gradle.kts で、releaseビルドについてこのフラグを落とします。
android {
buildTypes {
release {
vcsInfo {
include = false
}
}
}
}
公式ドキュメントに載っているのは debug 側を include = true にする例ですが、DSLの形は同じで、こちらはrelease側を false にしています。
アプリの挙動・依存関係・パーミッション・マニフェストには手を入れていません。APKからメタデータが1ファイル減るだけです。
バージョンを切り直した理由
この時点で既にv0.1.0を公開済みでした。ただしそのAPKは vcsInfo を落とす前のコミットからビルドしたものなので、F-Droidがpinするコミットからは再現できません。
公開済みのアセットを差し替えるのではなく、versionCodeを1から2へ、versionNameを0.1.0から0.1.1へ上げて新しいバージョンとして切り直しました。すでに配布されているAPKと中身の違うものを同じバージョン番号で置き換えると、後から追跡できなくなるためです。
罠2: gradle.properties先頭のUTF-8 BOM
症状
vcsInfo を落として :app:assembleRelease を実行したところ、今度はOutOfMemoryでビルドが完走しませんでした。
gradle.properties には、以前からヒープを増やす設定が入っていました。
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2048MBを指定しているのに足りない、という見え方です。しかし実際には、この行は最初から効いていませんでした。
なぜ -Xmx2048m が効かなかったか
ファイルの先頭バイトを見ると、キー名の前に3バイト入っていました。
$ git cat-file -p b881eaf | head -c 40 | od -An -tx1 -c
ef bb bf 6f 72 67 2e 67 72 61 64 6c 65 2e 6a 76
357 273 277 o r g . g r a d l e . j v
6d 61 72 67 73 3d 2d 58 6d 78 32 30 34 38 6d 20
m a r g s = - X m x 2 0 4 8 m
先頭の ef bb bf がUTF-8のBOM(U+FEFF)です。その直後に org.gradle.jvmargs=... が続いています。BOMはファイルの先頭、つまり最初のキー名の直前にありました。
gradle.properties はJavaのpropertiesファイルとして読まれます。java.util.Properties のドキュメントは load(InputStream) について次のように書いています。
The input stream is in a simple line-oriented format as specified in
load(Reader)and is assumed to use the ISO 8859-1 character encoding; that is each byte is one Latin1 character.
1バイトが1文字として扱われる形式で、BOMを読み飛ばす規定はありません。したがってBOMの3バイトはキー名の一部として取り込まれ、そのキーは org.gradle.jvmargs という名前と一致しません。
一致しないので、エラーにはなりません。単に「知らないキーが1つ書いてある」だけの扱いになり、-Xmx2048m はどこにも適用されないまま無視されます。設定が間違っているという警告も出ません。
OOMとのつながり
org.gradle.jvmargs が指定されていない場合の既定値は、Gradle公式ドキュメントに明記されています。
Default is
-Xmx512m "-XX:MaxMetaspaceSize=384m"
ヒープ512MB、メタスペース384MBです。Gradleデーモンはこの既定値のまま動いていて、:app:assembleRelease がそこで足りなくなっていました。
後から振り返ると、兆候は先に出ていました。この件の7週間ほど前の作業記録に、./gradlew lintDebug が既定のJVMヒープではMetaspace不足で失敗したこと、ヒープを一時的に引き上げれば完走することが書かれています。ただし当時はそれを「このリポジトリの org.gradle.jvmargs はlint workerには不足気味」という環境要因として記録していました。設定した値が小さいのだろう、という読みです。実際には値の問題ではなく、その設定が一度も読まれていませんでした。
「設定した値が効いていない」ときに、値そのものを疑ってさらに大きい数字を入れる方向へ進むと、原因からは離れていきます。
対応
先頭のBOMを除去しました。差分は1行目だけです。
-<BOM>org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
<BOM> と書いた箇所に表示される文字はありません。エディタ上では両者は同じ行に見えます。除去後にもう一度先頭バイトを確認すると、次のようになります。
$ head -c 40 gradle.properties | od -An -tx1 -c
6f 72 67 2e 67 72 61 64 6c 65 2e 6a 76 6d 61 72
o r g . g r a d l e . j v m a r
これで -Xmx2048m が適用されるようになり、releaseビルドが完走しました。
なお同じリポジトリ内のKotlin DSLファイル(build.gradle.kts)にはBOMが残っていますが、そちらはビルドに影響していません。今回問題になったのは、propertiesファイルとして1バイトずつ解釈される場所にBOMがあったことです。
clean buildでreproducibilityを再確認
2つを直したうえで、同じコミットからclean buildを3回行い、生成された未署名APKがバイト単位で一致することを確認しました。
比較対象を未署名APKにしているのは、F-Droidの検証が「署名を除いた部分の一致」を見るためです。署名を付けた後のファイルを比べても、署名そのものの差で必ず違いが出ます。
手元で同じ確認をするなら、ビルドごとに出力APKのハッシュを取って並べるのが簡単です。
# ビルドのたびに実行して値を比較する
Get-FileHash app\build\outputs\apk\release\app-release-unsigned.apk -Algorithm SHA256
3回とも同じ値になれば、少なくとも同一環境・同一コミットからの再現性は取れています。F-Droid側の環境との一致はまた別の話なので、ここで確認できるのは「自分の手元でビルドするたびに変わる要素は残っていない」ところまでです。
切り分けるときに見るポイント
今回の2件から、同じ形の問題に当たったときの見方をまとめておきます。
APKが一致しないとき
- まずソースの差ではなく、APKのどのエントリが違うかを見る。APKはzipなので展開して比較できる
-
META-INF/配下にはビルドツールが自動で入れるものがある。自分で書いた覚えのないファイルが原因のことがある - 「同じソースなのに一致しない」場合、変わっているのはソース由来ではない値(リビジョン、タイムスタンプ、ビルド環境情報)である可能性が高い
設定が効いていないとき
- 値を変えて試す前に、そのキーが本当に読まれているかを確認する
- propertiesファイルはISO 8859-1前提で1バイト=1文字として読まれる。BOMは読み飛ばされず、先頭キーの名前を変えてしまう
- エディタの「UTF-8」と「UTF-8(BOM付き)」は別物で、画面上は区別が付かない。疑うときはバイト列を直接見る
- 未知のキーは黙って無視される。設定ミスがエラーとして表面化しない種類の失敗がある
まとめ
reproducible buildへの対応で実際に手を入れたのは、vcsInfo を1箇所falseにしたことと、gradle.properties の先頭3バイトを削ったことの2点でした。
どちらもアプリのコードの問題ではありません。1つ目はビルドツールが既定で埋め込むメタデータ、2つ目は設定ファイルのエンコーディングです。「同じソースからビルドしているのに結果が変わる」「設定したはずの値が効かない」という症状は、コードを読んでも原因が出てこない場所に理由があることがあります。
なお現時点では、reproducible buildの要件を満たすところまで対応した段階です。F-Droid側の掲載可否は本記事の範囲外です。
参考資料
- Reproducible Builds | F-Droid
-
Analyze issues with App Quality Insights | Android Developers —
vcsInfoのDSLと、releaseビルドで既定有効である旨 -
The Build Environment | Gradle User Manual —
org.gradle.jvmargsと既定値 -
java.util.Properties| Java SE 21 API — propertiesファイルの読み込みとISO 8859-1