期限の13日前に確認して正解でした、というのが今回の率直な感想です。
Google Play の対象 API レベル36 の要求開始は 2026年8月31日。手元の Android アプリの app.json には、以前の作業で targetSdkVersion: 36 と書き込んであります。宣言は済んでいる、と思っていました。
実際にビルドが読んでいた値は 35 でした。
宣言と実効値がずれていたことに気づいたのは、提出直前の確認ではなく、期限までの残り日数を数えていたときの偶発です。もし提出の当日に気づいていたら、審査の並びに間に合っていたか怪しいところでした。以下は、そのずれがどこで生まれ、どう直し、そして引き上げないという選択をどのアプリに適用したかの記録です。
8月31日に変わることと、変わらないこと
先に一次情報を整理します。ここを曖昧にしたまま作業に入ると、直さなくてよいものまで直すことになります。
対象 2026年8月31日以降の要求
新規アプリ・アプリの更新の提出 Android 16(API レベル36)以上を対象にしていること
Wear OS / Android Automotive OS Android 15(API レベル35)以上
Android TV / Android XR Android 14(API レベル34)以上
既存アプリ(更新しない場合) API レベル35 以上であれば、対象 API レベルより新しい OS の端末でも新規ユーザーへの配信が続く
この表の最後の行が、今回の判断の分かれ目になりました。
更新を出さないアプリは、8月31日を過ぎても消えるわけではありません。 API レベル35 を満たしていれば、新しい端末の新規ユーザーにも届き続けます。止まるのは「更新の提出」です。ここを「全アプリを36に上げないと配信が止まる」と読み違えると、13日という残り時間の使い方を間違えます。
延長の窓口も用意されています。8月31日に間に合わない場合、Play Console のポリシー ステータス ページに出る警告の詳細から延長フォームを開き、2026年11月1日まで全ユーザーへの配信を継続できます。ただし延長は自動では付きません。申請そのものを期限内に済ませる必要があります。
targetSdkVersion は1か所では決まっていませんでした
Rork や Expo が出力するプロジェクトで targetSdkVersion を決めうる場所は、私が数えたかぎり3つあります。
app.json の expo-build-properties プラグイン設定
android/gradle.properties の android.targetSdkVersion
android/build.gradle の ext ブロックにあるフォールバック既定値
問題は、1が2に反映されるタイミングです。expo-build-properties は npx expo prebuild が native ディレクトリを生成するときに働く config plugin であり、公式ドキュメントにも「prebuild を実行しないプロジェクト(bare プロジェクト)では使えない」と明記されています。
つまり android/ をリポジトリにコミットしている状態では、app.json をいくら書き換えても、prebuild を通さないかぎり値は届きません。そして android/build.gradle の既定値は、そのプロジェクトを生成した当時の SDK バージョンのまま固まっています。
私の場合、app.json を触ったのは Expo のドキュメントを読みながらの作業で、そのあと android/ を先に生成していたプロジェクトが混ざっていました。宣言だけが更新され、ビルドが読む値は据え置き。ビルドは通り、署名も通り、AAB も出来上がります。何もエラーは出ません。
生成物をそのまま提出する運用ほど、この抜け方をします。ビルダーが動く以上、手を止める理由がどこにもないからです。
実効値を報告するスクリプトを書きました
「宣言」ではなく「ビルドが実際に読む値」を出す必要があったので、判定を書き下しました。目視で3ファイルを追う作業は、プロジェクトが数本を超えると必ず取りこぼします。
#!/usr/bin/env node
// audit-target-sdk.mjs
// app.json の宣言ではなく「ビルドが実際に読む値」を報告する
import { readFileSync, existsSync } from "node:fs" ;
import { join } from "node:path" ;
const REQUIRED = 36 ; // 2026-08-31 以降に Google Play が要求する値
function readJson ( p ) {
try { return JSON . parse ( readFileSync (p, "utf8" )); } catch { return null ; }
}
// (1) app.json の expo-build-properties が宣言している値
function declaredInConfig ( root ) {
const cfg = readJson ( join (root, "app.json" )) ?? readJson ( join (root, "app.config.json" ));
const plugins = cfg?.expo?.plugins ?? [];
for ( const p of plugins) {
if (Array. isArray (p) && p[ 0 ] === "expo-build-properties" ) {
return p[ 1 ]?.android?.targetSdkVersion ?? null ;
}
}
return null ;
}
// (2) android/gradle.properties が実際に持っている値
function inGradleProperties ( root ) {
const f = join (root, "android" , "gradle.properties" );
if ( ! existsSync (f)) return null ;
const m = readFileSync (f, "utf8" ). match ( / ^ android \. targetSdkVersion \s * = \s * ( \d + )/ m );
return m ? Number (m[ 1 ]) : null ;
}
// (3) android/build.gradle のフォールバック既定値
function fallbackInBuildGradle ( root ) {
const f = join (root, "android" , "build.gradle" );
if ( ! existsSync (f)) return null ;
const m = readFileSync (f, "utf8" )
. match ( /targetSdkVersion \s * = \s * Integer \. parseInt \( \s * findProperty \( 'android \. targetSdkVersion' \) \s * \? : \s * '( \d + )' \s * \) / );
return m ? Number (m[ 1 ]) : null ;
}
const roots = process.argv. slice ( 2 );
let failed = 0 ;
for ( const root of roots) {
const hasNativeDir = existsSync ( join (root, "android" ));
const declared = declaredInConfig (root);
const props = inGradleProperties (root);
const fallback = fallbackInBuildGradle (root);
// android/ がコミットされている場合、prebuild は既定で走らないため
// app.json の宣言はビルドに届かない。実効値は properties → build.gradle の順で決まる
const effective = hasNativeDir ? (props ?? fallback) : declared;
const source = hasNativeDir
? (props != null ? "android/gradle.properties" : "android/build.gradle (既定値)" )
: "app.json (prebuild で生成)" ;
const ok = effective != null && effective >= REQUIRED ;
if ( ! ok) failed ++ ;
console. log ( `${ root }` );
console. log ( ` app.json の宣言 : ${ declared ?? "(なし)"}` );
console. log ( ` android/ の有無 : ${ hasNativeDir ? "あり(bare 相当・prebuild は既定で走らない)" : "なし(managed)"}` );
console. log ( ` 実効 targetSdkVersion : ${ effective ?? "(不明)"} ← ${ source }` );
console. log ( ` 判定 : ${ ok ? "OK" : `NG(要 ${ REQUIRED } 以上)`}` );
console. log ( "" );
}
console. log ( `監査対象 ${ roots . length } 件 / NG ${ failed } 件` );
process. exit (failed > 0 ? 1 : 0 );
判定の順序を gradle.properties → build.gradle の順にしているのは、Gradle の findProperty がその順で解決するからです。build.gradle の ?: の右側は、あくまで properties に値がなかったときのフォールバックです。ここを逆に読むと、build.gradle に 36 と書けば済むと勘違いします。
android/ の有無で分岐させているのが要点です。ディレクトリが存在する時点で、そのプロジェクトは prebuild を毎回通す前提ではなくなっています。app.json の値は参考情報として表示するだけで、判定には使いません。
実行した結果がこちらです。
$ node audit-target-sdk.mjs managed bare
managed
app.json の宣言 : 36
android/ の有無 : あり(bare 相当・prebuild は既定で走らない)
実効 targetSdkVersion : 36 ← android/gradle.properties
判定 : OK
bare
app.json の宣言 : 36
android/ の有無 : あり(bare 相当・prebuild は既定で走らない)
実効 targetSdkVersion : 35 ← android/build.gradle (既定値)
判定 : NG(要 36 以上)
監査対象 2 件 / NG 1 件
2件とも app.json の宣言は 36 です。それでも実効値は 36 と 35 に割れました。app.json を grep して確認する運用では、この差が一切見えません。
終了コードを NG 件数で返しているので、CI やリリース前スクリプトの手前に置けます。私は提出前チェックの一段目に組み込みました。
直した3か所
直した内容自体は地味です。地味なぶん、気づくかどうかで結果が分かれます。
場所 Before After
android/gradle.properties 行そのものが無い android.targetSdkVersion=36 と android.compileSdkVersion=36 を追記
android/build.gradle の ext フォールバックが '35' '36' に更新(properties を消したときの保険)
app.json の expo-build-properties targetSdkVersion: 36 のみcompileSdkVersion と buildToolsVersion: "36.0.0" も併記
3つ目について補足します。targetSdkVersion だけを上げて compileSdkVersion を据え置くと、Android 16 で追加された API を参照した時点でコンパイルが落ちます。逆に compileSdkVersion だけ上げても、Play の要求は満たされません。
審査が見るのは targetSdkVersion、コンパイルが見るのは compileSdkVersion。 別々の役割を持つ値なので、片方だけ上げる状態を作らないほうが安全です。buildToolsVersion を併記したのは、後から別の環境でビルドしたときに解決される版が揺れないようにするためです。
最終確認は、AAB を Play Console にアップロードしたあとの App bundle エクスプローラで行いました。ここに表示される対象 API レベルが、Play が実際に読み取った値です。ソース側の監査が通っていても、この画面で 35 と出たら直っていません。
引き上げ・更新停止・延長申請をどう分けたか
ここからが今回の本題です。全部を 36 に上げるのが正解、とは考えませんでした。
私が Google Play に出しているのは壁紙・癒し系のアプリ群で、更新の頻度はアプリごとにばらばらです。毎月のように画像アセットを足しているものもあれば、機能が固まっていて半年以上さわっていないものもあります。この差を無視して一律に引き上げると、13日で全部の実機確認まで終える計画になり、確実に破綻します。
判断はこう分けました。
状況 選んだ手 理由
期限までに更新を出す予定がある 36 に引き上げて実機確認まで通す 更新の提出自体が 36 を要求するため、迂回路がない
更新の予定がなく、実効値が 35 以上 何もしない(今期は据え置き) 新規ユーザーへの配信は継続する。急いで触ると回帰の危険だけが増える
更新を出したいが、Android 16 の挙動確認が終わらない 延長申請を出したうえで引き上げ作業を続ける 11月1日まで猶予が取れる。申請は期限内に出す必要がある
真ん中の行に手を出さないと決めたのが、いちばん効きました。
私は当初、期限という言葉を見た時点で「全部やる」に寄っていました。表を書いて初めて、更新予定のないアプリに割く時間が丸ごと不要だと分かりました。期限は「全アプリの締切」ではなく「更新を出すアプリの締切」です。
延長申請を採用する場合は、出せるなら早めに出しておくほうが良いと考えています。申請を出したうえで期限内に引き上げが終われば、申請は無駄になるだけで害はありません。逆に、引き上げが終わらないまま8月31日を越えると、申請の窓口ごと使えなくなります。片方は損失がなく、もう片方は取り返しがつかない。この非対称性がある場面では、早く動くほうを選んでいます。
引き上げたあとに実機で見た箇所
targetSdkVersion を上げるということは、OS に対して「新しい挙動で動かして構いません」と宣言することです。ビルドが通ることと、挙動が変わらないことは別の話でした。
私が実機で優先して見たのは次の3点です。
画面端のインセット — edge-to-edge の扱いが変わると、下端のナビゲーションバーにコンテンツが潜り込みます。壁紙アプリはプレビューを全画面に近い形で出すので、ここは真っ先に確認しました。API 35 に上げたときに同じ問題を踏んでいます(Rork で targetSdkVersion 35 にしたら Android のレイアウトが下まで埋まる問題の直し方 )。
画像の保存とメディアアクセス — ストレージ関連の権限は対象 API レベルの変更で最も挙動が動く領域です。壁紙の端末保存が通ることを、実機で1回ずつ確かめました。
通知の表示 — 権限リクエストのタイミングと、拒否されたあとの再要求の扱いを確認しました。
エミュレータだけで済ませなかったのは、権限まわりが OS のバージョンと端末メーカーの実装の両方に影響されるためです。個人開発では手元に置ける実機の台数が限られますが、それでも1台通すのとゼロ台は違います。実機で1回動かしてから提出する順序は、いまも崩さないようにしています。
なお、android/ を生成し直す方向で揃える選択肢もあります。その場合は prebuild で手書きのネイティブ変更が消える点に注意が必要です(Expo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出す )。今回は期限が近かったので、生成し直さず properties を直す最小の手を選びました。
いま手を動かすなら、最初の1つ
app.json を開く前に、android/gradle.properties を開いてください。
そこに android.targetSdkVersion の行が無ければ、app.json に何が書いてあっても、あなたのビルドは build.gradle の既定値で動いています。私はそれを、期限の13日前に知りました。
この監査は、うまくいけば何も起きません。何も起きないことにどれだけ意味があるかは、提出がはねられた経験のある方ほど分かっていただけるのではないかと思います。私自身、期限まわりの読み違いは何度もやっていて、今回も表を書くまでは全アプリを触るつもりでいました。お読みいただきありがとうございました。