targetSdkVersion を 36 に書き換えて、ビルドが通ったところまでは私も同じでした。引っかかったのはそのあとです。「依存ライブラリ側が新しい API レベルに追随していないと、ローカルのビルドは通ってもストアのプリチェックで止まることがある」という話を読んで、では自分は依存側の何を見ればいいのか、が分からないことに気づきました。
個人開発で複数のアプリを並行して抱えていると、期限の前日にやれることは限られます。全部のライブラリの更新履歴を追う時間はありません。せめて「自分のアプリのマニフェストに、依存が何を書き足しているか」だけでも機械的に出せないか。そう思って手を動かした記録です。
先に結論を書きます。依存ライブラリの AndroidManifest に targetSdkVersion は書かれていません。 私が実際に6本の AAR を開いて確かめた範囲では、1本も宣言していませんでした。「依存の targetSdk を棚卸しする」という発想そのものが、存在しない値を探しに行く作業だったわけです。
依存側のマニフェストを6本、実際に開いて数えました
Google の Maven リポジトリから、Expo ベースのアプリでよく入るライブラリの AAR をそのまま取得して、中の AndroidManifest.xml を読みました。AAR の中のマニフェストはバイナリ XML ではなくテキストのままなので、Android SDK も Gradle も要りません。zip を開いて読むだけです。
結果が以下です。uses-sdk の行に何が書かれていたかを、そのまま並べています。
| 依存(バージョン) | uses-sdk の宣言 | targetSdkVersion |
| androidx.core:core 1.13.1 | minSdkVersion="19" | 宣言なし |
| androidx.work:work-runtime 2.9.1 | minSdkVersion="14" | 宣言なし |
| androidx.media3:media3-session 1.4.1 | minSdkVersion="19" | 宣言なし |
| com.google.android.gms:play-services-ads-lite 23.3.0 | minSdkVersion="21" | 宣言なし |
| com.google.android.gms:play-services-measurement-api 22.1.2 | minSdkVersion="21" | 宣言なし |
| com.google.firebase:firebase-messaging 24.0.1 | minSdkVersion="21" | 宣言なし |
6本中6本が minSdkVersion だけを持っていて、targetSdkVersion は持っていません。これは手抜きでも古さでもなく、そういう仕組みだからです。
ライブラリ側の targetSdkVersion は、アプリの成果物には効きません。Android の behavior change はプロセス単位で、アプリの targetSdkVersion を見て適用されます。ライブラリのコードもそのプロセスの中で動く以上、アプリが 36 を宣言した瞬間、ライブラリのコードも 36 の規則の下で動き始めます。ライブラリが「自分は 34 のつもりです」と主張する余地はどこにもありません。
つまり、期限直前に起きる問題の構図はこうです。アプリの値を上げると、追随していないライブラリのコードが、新しい規則の下で動かされる。ライブラリ側の宣言を見ても、その危険は読み取れません。読み取れないものを探しても仕方がないので、見る対象を変える必要がありました。
代わりに入ってくるのは、権限とサービスと queries と property です
targetSdkVersion は入ってきませんが、別のものは確実に入ってきます。同じ6本を対象に、依存が自分のマニフェストへ書き足す要素を数えたところ、重複を除いて12個の権限が入っていました。私は1行も書いていません。
内訳の一部です。
| 依存 | 持ち込まれるもの |
| play-services-ads-lite 23.3.0 | 権限6個(INTERNET / ACCESS_NETWORK_STATE / AD_ID / ACCESS_ADSERVICES_AD_ID / ACCESS_ADSERVICES_ATTRIBUTION / ACCESS_ADSERVICES_TOPICS)、queries ブロック1個、property 1個、service 1個、activity 3個 |
| work-runtime 2.9.1 | 権限4個(WAKE_LOCK / ACCESS_NETWORK_STATE / RECEIVE_BOOT_COMPLETED / FOREGROUND_SERVICE)、service 3個、receiver 7個 |
| firebase-messaging 24.0.1 | 権限4個(ACCESS_NETWORK_STATE / POST_NOTIFICATIONS / WAKE_LOCK / c2dm.permission.RECEIVE)、service 2個 |
| play-services-measurement-api 22.1.2 | 権限6個、service 1個、property 1個 |
| androidx.core 1.13.1 | DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION(アプリの applicationId が前置される) の定義と使用 |
| media3-session 1.4.1 | なし(uses-sdk のみ) |
media3-session のように、何も足さない依存もあります。6本のうち1本がこれでした。全部を疑う必要はなく、足しているものだけを見ればよい、という切り分けがここで立ちます。
注目したのは queries と property です。queries はパッケージ可視性の宣言で、他アプリの存在を問い合わせられる範囲を決めます。property は AdServices の設定ファイルを指しています。どちらも権限一覧を眺めているだけでは目に入りません。私自身、AdMob の SDK(play-services-ads)を入れているアプリに <property android:name="android.adservices.AD_SERVICES_CONFIG"> が入っていることを、今回まで意識していませんでした。広告収益のあるアプリなら、まず確実に入っている要素です。
Android SDK なしで洗い出すスクリプト
手元で回したものをそのまま置きます。AAR と、Expo モジュールが android/src/main/AndroidManifest.xml として持っている素の XML の両方を対象にしています。Rork や Expo で作ったプロジェクトは、依存の実体が node_modules 配下のソースと Gradle キャッシュの AAR に分かれて存在するので、片方だけ見ても穴が残ります。
#!/usr/bin/env python3
"""依存側の AndroidManifest が何を持ち込むかを、Android SDK なしで洗い出す。
使い方:
python3 manifest_contributors.py node_modules ~/.gradle/caches/modules-2
出力: 依存ごとの uses-sdk / 追加される権限 / 削除される権限 /
service(foregroundServiceType の有無)/ queries / property
"""
import sys
import os
import zipfile
from xml.etree import ElementTree as ET
AND = "{http://schemas.android.com/apk/res/android}"
TOOLS = "{http://schemas.android.com/tools}"
def read_manifests(roots):
"""(表示名, XML文字列) を列挙する。AAR は zip 内、Expo モジュールは素の XML。"""
for root in roots:
for dirpath, dirnames, filenames in os.walk(root):
# 生成物を掘ると同じマニフェストを二重に数えるので除外する
dirnames[:] = [d for d in dirnames if d not in ("build", ".git")]
for fn in filenames:
p = os.path.join(dirpath, fn)
if fn.endswith(".aar"):
try:
with zipfile.ZipFile(p) as z:
yield os.path.basename(p), z.read(
"AndroidManifest.xml"
).decode("utf-8", "replace")
except (zipfile.BadZipFile, KeyError, OSError):
# マニフェストを持たない AAR や壊れた zip は黙って飛ばす
continue
elif fn == "AndroidManifest.xml" and os.sep + "src" + os.sep in p:
try:
with open(p, "r", encoding="utf-8", errors="replace") as f:
yield os.path.relpath(p, root), f.read()
except OSError:
continue
def analyze(name, xml):
try:
root = ET.fromstring(xml)
except ET.ParseError:
return None
rec = {
"name": name,
"min_sdk": None,
"target_sdk": None,
"permissions": [],
"removed": [],
"services": [],
"queries": 0,
"properties": [],
}
for u in root.findall("uses-sdk"):
rec["min_sdk"] = u.get(AND + "minSdkVersion")
rec["target_sdk"] = u.get(AND + "targetSdkVersion") # 実際には None になる
perms = root.findall("uses-permission") + root.findall("uses-permission-sdk-23")
for p in perms:
nm = p.get(AND + "name")
# tools:node="remove" は「足す」ではなく「消す」宣言なので分けて数える
if p.get(TOOLS + "node") == "remove":
rec["removed"].append(nm)
else:
rec["permissions"].append(nm)
rec["queries"] = len(root.findall("queries"))
app = root.find("application")
if app is not None:
for s in app.findall("service"):
rec["services"].append((s.get(AND + "name"), s.get(AND + "foregroundServiceType")))
for pr in app.findall("property"):
rec["properties"].append(pr.get(AND + "name"))
return rec
def main(roots):
seen_perm = {}
rows = []
for name, xml in read_manifests(roots):
rec = analyze(name, xml)
if not rec:
print(" parse error: " + name)
continue
# 何も持ち込まない依存は出力しない(媒体ライブラリの多くがこれに当たる)
if not any([rec["permissions"], rec["removed"], rec["services"],
rec["queries"], rec["properties"]]):
continue
rows.append(rec)
for perm in rec["permissions"]:
seen_perm.setdefault(perm, []).append(rec["name"])
for r in sorted(rows, key=lambda x: x["name"]):
print("== " + r["name"])
print(" uses-sdk: min=" + str(r["min_sdk"]) + " target=" + str(r["target_sdk"]))
for perm in r["permissions"]:
print(" + permission " + str(perm))
for perm in r["removed"]:
print(" - permission " + str(perm) + " (tools:node=remove)")
for nm, fgs in r["services"]:
print(" * service " + str(nm) + " foregroundServiceType=" + (fgs or "なし"))
if r["queries"]:
print(" * queries ブロック " + str(r["queries"]) + " 個(パッケージ可視性)")
for prop in r["properties"]:
print(" * property " + str(prop))
print("")
print("--- 集計 ---")
print("解析した依存: " + str(len(rows)))
print("注入される権限(重複除去): " + str(len(seen_perm)))
multi = {k: v for k, v in seen_perm.items() if len(v) > 1}
for k, v in sorted(multi.items()):
print(" 重複宣言 " + k + " ← " + ", ".join(v))
no_target = [r["name"] for r in rows if r["target_sdk"] is None]
print("targetSdkVersion を宣言していない依存: "
+ str(len(no_target)) + " / " + str(len(rows)))
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
main(sys.argv[1:])
走らせるときの手順
node_modules と Gradle キャッシュ(~/.gradle/caches/modules-2)の両方を引数に渡します。片方だけだと、Expo モジュール側か AAR 側のどちらかが丸ごと抜け落ちます。
- 出力をそのままファイルへ落として、リポジトリに置きます。この段階では読み込まなくて構いません。
- 気になった行だけを、次の節で触れるマージ後のマニフェストと突き合わせます。Gradle を動かすのはここが初めてです。
この順序を守ると、重い assembleRelease を何度も回すことを回避できます。逆にすると、出力を眺めるたびにビルドし直すことになりました。
書きながら二度直した箇所が2つあります。
ひとつは dirnames から build を外す行です。最初は素直に全部歩いていたのですが、Gradle が生成した中間成果物の中に展開済みのマニフェストが残っていて、同じライブラリが二重・三重に出てきました。走査対象から生成物を落とすだけで二重計上を回避でき、出力の見通しがまるで変わります。
もうひとつは tools:node="remove" を持つ uses-permission を、追加の側に数えないようにした点です。最初の版は uses-permission を無条件に「追加された権限」として数えていて、実際には削除の宣言であるものを足し算していました。次の節で書くとおり、この取り違えは結論を反転させます。
実行して出てきたもの
先ほどの6本を1つのディレクトリに置いて走らせた出力です。長いので、要点の部分だけ抜きます。
== play-services-ads-lite-23.3.0.aar
uses-sdk: min=21 target=None
+ permission android.permission.INTERNET
+ permission android.permission.ACCESS_NETWORK_STATE
+ permission com.google.android.gms.permission.AD_ID
+ permission android.permission.ACCESS_ADSERVICES_AD_ID
+ permission android.permission.ACCESS_ADSERVICES_ATTRIBUTION
+ permission android.permission.ACCESS_ADSERVICES_TOPICS
- permission android.permission.RECEIVE_BOOT_COMPLETED (tools:node=remove)
* service com.google.android.gms.ads.AdService foregroundServiceType=なし
* queries ブロック 1 個(パッケージ可視性)
* property android.adservices.AD_SERVICES_CONFIG
== work-runtime-2.9.1.aar
uses-sdk: min=14 target=None
+ permission android.permission.WAKE_LOCK
+ permission android.permission.ACCESS_NETWORK_STATE
+ permission android.permission.RECEIVE_BOOT_COMPLETED
+ permission android.permission.FOREGROUND_SERVICE
* service androidx.work.impl.foreground.SystemForegroundService foregroundServiceType=なし
--- 集計 ---
解析した依存: 5
注入される権限(重複除去): 12
重複宣言 android.permission.ACCESS_NETWORK_STATE ← play-services-ads-lite, play-services-measurement-api, work-runtime, firebase-messaging
重複宣言 android.permission.WAKE_LOCK ← play-services-measurement-api, work-runtime, firebase-messaging
重複宣言 com.google.android.gms.permission.AD_ID ← play-services-ads-lite, play-services-measurement-api
targetSdkVersion を宣言していない依存: 5 / 5
同じ権限を複数のライブラリが宣言している行が、思っていたより多く出ました。これ自体は問題ではありません。効いてくるのは、ライブラリを1本外したときです。ACCESS_NETWORK_STATE は4本が宣言しているので、1本抜いても消えません。一方で POST_NOTIFICATIONS は firebase-messaging だけが宣言していて、これを外すと権限ごと消えます。「このライブラリを外したら、ユーザーに見える挙動のどこが変わるのか」を、この重複表から先に読めます。
依存どうしが打ち消し合っている場所があります
出力を見ていて手が止まったのが、この2行の組み合わせでした。
work-runtime-2.9.1.aar
+ permission android.permission.RECEIVE_BOOT_COMPLETED
play-services-ads-lite-23.3.0.aar
- permission android.permission.RECEIVE_BOOT_COMPLETED (tools:node=remove)
WorkManager は再起動後にジョブを組み直すため RECEIVE_BOOT_COMPLETED を足します。広告 SDK は同じ権限を tools:node="remove" で消しにいきます。両方を入れているアプリで、この2つの宣言は同じ要素に対して逆向きに働きます。
マニフェストのマージは、足し算だけの操作ではありません。依存が、他の依存やあなたの宣言を消す側に回ることがあります。 これを知らずに「依存が足す権限の一覧」だけを作ると、実際の成果物と食い違います。私の最初のスクリプトが tools:node="remove" を追加側に数えていたのは、まさにこの落とし穴に自分で落ちていたということです。
どちらが最終的に残るかは、マージの優先度で決まります。アプリ自身のマニフェストが最上位で、ライブラリ側は依存の順序で並びます。ここは静的な一覧から断定できる部分ではないので、私は結論を出さずに「マージ後を読む」へ切り替えました。読むべきファイルは2つです。
| ファイル | 分かること |
app/build/outputs/logs/manifest-merger-(ビルド種別)-report.txt | どの宣言が、どのマニフェスト由来で、どう処理されたか(採用・上書き・削除) |
app/build/intermediates/merged_manifests/(ビルド種別)/AndroidManifest.xml | 実際に APK/AAB へ入る最終形 |
依存の一覧を作る目的は、この2つを読むときの当たりをつけることです。何百行もあるマージ後のマニフェストを頭から読むのは現実的ではありませんが、「RECEIVE_BOOT_COMPLETED の行だけ確かめる」なら1分で終わります。順序としては、まず持ち込み一覧、次にマージ後の該当行、です。逆にすると時間が溶けます。
マニフェストでは判定できない項目を、先に切り離しておきます
もうひとつ、出力を見て予想が外れた箇所があります。foregroundServiceType がどこにも書かれていないことです。
Android 14 以降、フォアグラウンドサービスには型の宣言が要ります。ならばライブラリ側のマニフェストにも書かれているはずだと思って探したのですが、work-runtime の SystemForegroundService — 名前のとおり、まさにフォアグラウンドサービスそのもの — に型がありませんでした。バージョンが古いせいかと考えて 2.10.0 と 2.10.1 も取得して開きましたが、どちらも同じでした。
| work-runtime のバージョン | SystemForegroundService の foregroundServiceType |
| 2.9.1 | 宣言なし |
| 2.10.0 | 宣言なし |
| 2.10.1 | 宣言なし |
追随の遅れではなく、設計です。WorkManager は、あなたが ForegroundInfo に渡した型を実行時に使います。型を決めるのはライブラリではなく、あなたのコードです。だからライブラリのマニフェストには書きようがありません。
この事実の実務的な意味は、はっきりしています。フォアグラウンドサービス型のコンプライアンスは、マニフェストの静的な確認では判定できません。 一覧に「型なし」と並んでいても、それは違反の証拠ではありませんし、逆に一覧が綺麗でも安全の証明にはなりません。ここは実機で対象のジョブを起動して、ForegroundInfo に何を渡しているかをコード側で確かめる工程です。
期限前に時間がないときほど、この切り分けが効きます。静的に潰せるものと、動かさないと分からないものを混ぜたまま眺めていると、どちらも中途半端に終わります。
期限の前日に、この出力から決めたこと
出てきた一覧を、3つに仕分けました。判断の軸は「提出が止まるか」「ユーザーに見えるか」「今日中に確かめ切れるか」の3つです。
| 分類 | 該当したもの | 今日やること |
| 提出前に必ず見る | 依存どうしで衝突している権限、身に覚えのない queries と property | マージ後のマニフェストで該当行だけ確認する |
| 提出後でよい | 単一の依存だけが宣言している権限、重複宣言 | 一覧を保存して、次のライブラリ更新時に差分を取る |
| 静的確認の対象外 | foregroundServiceType、実行時に決まる挙動 | 実機で該当機能を1回動かす。マニフェスト側では判断しない |
queries と property を上の段に置いたのは、ストアの申告と直結するからです。パッケージ可視性の宣言も、AdServices の設定も、自分が書いた覚えがないまま入っているのに、聞かれるのは自分です。値そのものより「なぜ入っているか説明できる状態か」を先に整えるほうが、あとで楽でした。
なお、自分のプロジェクト側の実効 targetSdkVersion を数え上げる話は、Rork で生成した Android アプリの実効 targetSdkVersion を棚卸しする に分けて書いています。今回の話は、その棚卸しでは出てこない側 — 依存が持ち込む要素 — を対象にしたものです。ネイティブライブラリのページサイズについては 16 KB ページサイズの警告と8月31日の期限 が別軸になります。
手を動かして残ったもの
期限に追われているときほど、確認の対象を減らすほうが結果は良くなります。今回でいえば、「依存の targetSdk を確かめる」は最初から成立しない作業でした。存在しない値を探す時間を、衝突している2行を見つける時間に振り替えられたのが、この半日の収穫です。
次の一歩を1つだけ挙げるなら、上のスクリプトを自分の node_modules と Gradle キャッシュに向けて一度走らせ、その出力をリポジトリに commit しておくことをお勧めします。次にライブラリを更新したとき、diff を取るだけで「何が増えて何が消えたか」が分かります。一覧そのものより、一覧の差分のほうが長く役に立ちます。
私自身、依存が何を書き足しているかをこの粒度で見たのは今回が初めてでした。同じように期限の前で手が止まっている方の、確認範囲を狭める材料になれば嬉しく思います。お読みいただきありがとうございました。