個人開発で iOS アプリを1本リリースしました。App Store 審査は 3回(却下2回 → 承認)。落ちた原因はどちらもコードの質とは無関係な「知っているかどうか」の問題で、知らないと丸2日溶けます。
この記事は、その2件の審査リジェクトと、実装中に地味に効いた設計判断2つを、そのまま流用できる形で残すためのものです。ポエムは最後に少しだけ置きました。
リリース直後のため、ダウンロード数・ユーザー数・収益について書けることは何もありません。この記事は 実装と審査の通し方だけ の話です。
TL;DR
-
ATTのダイアログは
UIApplicationStateがactiveのときしか出ない。 出せなかったときAPIはエラーを返さず、undetermined(未回答)をそのまま返す。ここを「ユーザーが拒否した」と誤読すると審査で 2.1 を食らう -
SQLiteのマイグレーションは追加のみ(
CREATE TABLE/ALTER TABLE ADD COLUMN)。PRAGMA user_versionの更新をスキーマ適用と同一トランザクションに入れる。連番検証とロールバックのテストを書く -
時間依存ロジックからは
Date.now()を全部追い出し、now: number/today: 'YYYY-MM-DD'を引数で受ける。jest.useFakeTimers()は一度も要らなくなる - 自動更新サブスクを出すなら、アプリ説明文(App Description)にEULAのリンクを直接書く。 アプリ内のペイウォールにリンクがあるだけでは 3.1.2 で落ちる
動作環境
| 項目 | バージョン・構成 |
|---|---|
| Expo SDK | 54 |
| React Native | 0.81 |
| 言語 | TypeScript(strict、any 禁止) |
| ローカルDB |
expo-sqlite(スキーマ v1 → v9) |
| 広告 |
react-native-google-mobile-ads(AdMob) |
| ATT | expo-tracking-transparency |
| 課金 | RevenueCat |
| 計測 | PostHog |
| ビルド | EAS Build(開発ビルド / production) |
| テスト | Jest(366件 / 25スイート) |
| 対象OS | iOS(iPhone専用・タブレット非対応) |
ネイティブモジュールを含むため、Expo Go では動きません。EAS の開発ビルドが前提です。ATT・広告・課金まわりを触ったら、そのたびに開発ビルドの作り直しが要ります。
作ったもの(1段落だけ)
QuesToDo — やることを「クエスト」として登録し、完了するとEXPが入ってドット絵の冒険者がレベルアップするToDoアプリ。iPhone専用・日英対応・データは全部端末内(サーバーなし)。スキル52種、アバターパーツのカタログ84項目(うち3つは「装備しない」の選択肢なので、実際の絵は81点)、実績称号16種。ドット絵PNGは108枚(パーツ81 + 発動エフェクト27=9系統×3フレーム)で、全部 pngjs で書いた生成スクリプトの出力です。手描きは1枚もありません。
実装コードは 14,451行(App.tsx + src 配下 86ファイル、テスト除外・空行込み)。開発期間は 2026-07-16 〜 2026-08-07 の23日で、相棒はAIエージェント(Claude Code)、設計判断と実機検証は自分がやりました。
① ATTダイアログが審査環境でだけ出ない(Guideline 2.1)
一番ハマったのがこれです。受け取った指摘はこの趣旨でした。
Guideline 2.1 — We were unable to locate the App Tracking Transparency permission request when reviewed on iOS.
手元の iPhone では出ていました。出ていなかったのは審査環境だけです。
真因
ATTのダイアログは、アプリが UIApplicationState.active のときにしか表示されません。 そして提示が見送られたとき、APIはエラーを返しません。ダイアログを出さないまま undetermined(未回答)を返して終わります。
自分のコードは、
- 起動直後(スプラッシュ解除中=まだ
activeでない)に要求し、 - 返ってきた
undeterminedを「grantedではない = 拒否された」と解釈して、 - プロセス内で二度と要求しない
という三段構えで、見事に一度きりのチャンスを溶かしていました。手元では起動が速くたまたま active に間に合っていた、それだけの差です。
さらに厄介なことに、Apple Developer Forums や各所の報告では 「active でない状態で一度呼ぶと、以後 active で呼び直してもポップアップが出なくなる」 ケースが挙がっています。つまり リトライ以前に「inactive のときは絶対に呼ばない」ことが本質です。
手元で再現させる手順
審査環境そのものは再現できませんが、同じ症状は手元で作れます。
1. ATTの状態を「未回答」に戻す
設定 → 一般 → 転送またはiPhoneをリセット → リセット → 位置情報とプライバシーをリセット
アプリの削除・再インストールだけでは戻らないことがあります。これが一番確実です(端末の他の権限も一緒にリセットされる点に注意)。
2. 前提条件を先に潰す
ここが崩れていると、コードをいくら直しても出ません。
-
設定→プライバシーとセキュリティ→トラッキング→ 「Appからのトラッキング要求を許可」がONであること。OFFだとダイアログは出ずdeniedが返る -
Info.plistにNSUserTrackingUsageDescriptionがあること - 他の権限ダイアログ(通知・位置情報など)を同時に出していないこと。別の権限要求が未応答のままだとATTは提示されない
Expo(CNG)なら app.json はこうなります。
{
"expo": {
"ios": {
"infoPlist": {
"NSUserTrackingUsageDescription": "広告の最適化のために使用されます。拒否しても全機能を利用できます。"
}
},
"plugins": [
"expo-tracking-transparency",
[
"react-native-google-mobile-ads",
{
"iosAppId": "ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX"
}
]
]
}
}
3. 呼ぶ直前と直後をログに出す
import { AppState } from 'react-native';
import { requestTrackingPermissionsAsync } from 'expo-tracking-transparency';
console.log('[ATT] before', AppState.currentState);
const { status } = await requestTrackingPermissionsAsync();
console.log('[ATT] after', status, AppState.currentState);
4. 起動直後(最初のレンダー前)にこれを呼ぶ
ログがこうなり、ダイアログが一度も出なければ再現成功です。
[ATT] before inactive
[ATT] after undetermined inactive
before が active なのに出ない場合は、原因はタイミングではなく前提条件(手順2)か、既に回答済み(granted / denied が返る)です。
修正(3点セット)
(1) active になるまで待ってから要求する
/** activeを待つ上限。購読を張る直前にactiveへ遷移した取りこぼしへの保険 */
const ACTIVE_WAIT_TIMEOUT_MS = 10_000;
function waitUntilActive(): Promise<void> {
if (AppState.currentState === 'active') {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
subscription.remove();
clearTimeout(timer);
resolve();
};
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') finish();
});
const timer = setTimeout(finish, ACTIVE_WAIT_TIMEOUT_MS);
// 購読を張るまでの間にactiveへ遷移していた場合の取りこぼしを拾う
if (AppState.currentState === 'active') finish();
});
}
ポイントは2つ。
-
タイムアウトを必ず置く。
activeに永遠にならない環境で Promise が pending のまま残ると、後続の広告初期化ごと止まります -
購読を張った直後にもう一度
currentStateを見る。addEventListenerを呼ぶ前の一瞬にactiveへ遷移していると、changeイベントは二度と来ません
(2) undetermined は「拒否」ではなく「提示失敗」として扱い、間隔を空けて再試行する
/**
* ATTダイアログの提示を試みる間隔(ミリ秒)。要素数=最大試行回数。
* iOSはUIのトランジション中(起動直後のスプラッシュ解除・モーダルの提示/解除
* アニメーション)だと提示を見送ることがある。その場合エラーにはならず
* undetermined のまま返るため、提示できるまで間隔を空けて再試行する。
*/
const ATT_ATTEMPT_DELAYS_MS = [600, 1_500, 3_000, 5_000, 8_000] as const;
export function ensureTrackingConsent(): Promise<boolean> {
if (trackingConsent === null) {
trackingConsent = (async (): Promise<boolean> => {
// ATT非対応環境(iOS 13以下・Android等)はライブラリがgranted相当を返す
if (!isTrackingApiAvailable()) return true;
const current = await getTrackingStatus();
if (current !== 'undetermined') {
return current === 'granted'; // 回答済みなら終わり
}
for (const settleMs of ATT_ATTEMPT_DELAYS_MS) {
await waitUntilActive();
await delay(settleMs);
// 待機中にinactiveへ落ちていたら要求しない(出ないまま消費されるため)
if (AppState.currentState !== 'active') continue;
const { status } = await requestTrackingPermissionsAsync();
const normalized = toTrackingStatus(status);
if (normalized !== 'undetermined') {
return normalized === 'granted'; // 出た(許可でも拒否でも決着)
}
// 提示が見送られた(トランジション中など)。間隔を空けて再試行する
}
return false;
})();
}
return trackingConsent;
}
await waitUntilActive() の後にもう一度 AppState.currentState !== 'active' を確認しているのが肝です。delay() している最中にモーダルが開いたり通知ダイアログが出たりすると inactive に落ち、そこで要求すると「出ないまま1回消費」になります。
ステータスの正規化はこれだけ。restricted も denied に畳んで、呼び出し側の分岐を3値に固定します。
export type TrackingStatus = 'undetermined' | 'granted' | 'denied';
function toTrackingStatus(status: string): TrackingStatus {
if (status === 'granted') return 'granted';
return status === 'undetermined' ? 'undetermined' : 'denied';
}
(3) 設定画面に手動導線を置く
自動要求がどうしてもすべる環境はあります。設定画面に「広告のトラッキング設定」の行を足し、そこから明示的に要求できるようにしました。審査担当者が自分で辿り着ける経路を用意しておく、という意味でも効きます。
/**
* ATTダイアログを即座に要求する(設定画面からの明示操作用)。
* 回答済みの端末ではiOSがダイアログを出さないため、呼び出し側で状態を見て
* 「設定アプリを開く」導線に切り替える
*/
export async function requestTrackingNow(): Promise<TrackingStatus> {
if (!isTrackingApiAvailable()) return 'granted';
const { status } = await requestTrackingPermissionsAsync();
const normalized = toTrackingStatus(status);
nonPersonalizedOnly = normalized !== 'granted';
return normalized;
}
回答済み(granted / denied)の端末ではiOSがダイアログを出しません。ここで「ボタンを押したのに何も起きない」を作らないよう、回答済みなら Linking.openSettings() に切り替えるのを忘れずに。
ATTを待つと広告が止まる問題
素直に書くと「ATTの解決を待つ → 広告SDK初期化 → ロード」になりますが、ATTのリトライは最大で十数秒かかります。その間バナーが真っ白になるのは避けたいので、ATTの完了は待たずに初期化を進め、未許可の間は非パーソナライズ広告だけ要求する構成にしました。
/** ATT拒否時(未回答含む)はtrue = 非パーソナライズ広告のみ要求する */
let nonPersonalizedOnly = true;
export function initializeAds(): Promise<void> {
if (initialization === null) {
ensureTrackingConsent().catch(() => {}); // 完了は待たない
initialization = mobileAds().initialize().then(() => undefined);
}
return initialization;
}
react-native-google-mobile-ads の requestOptions(requestNonPersonalizedAdsOnly など)は広告インスタンスの生成時に固定されます。ATTの結果が後から確定しても、既存インスタンスには反映されません。リワード広告のように使い捨てるものは、ロードのたびに新しいインスタンスを作る必要があります。
審査への出し方
- 審査ノートに操作手順(起動 → ダイアログ表示、および設定画面からの再要求手順)を書く
- 実機の画面収録(起動 → ATTダイアログ表示)を添付する
これで3回目に通りました。
AppState.currentState === 'active' を確認してから呼ぶ。undetermined はリトライ対象。この2つだけ覚えて帰ってください。expo-tracking-transparency でも生の ATTrackingManager でも、提示側の条件は同じです。
なお「リトライすべき」とAppleが明文で書いているわけではありません。これは自分の環境でこう直したら通った、という体験ベースの話です。
② SQLiteの追記型マイグレーションで v1 → v9 を事故ゼロで回す
ローカルDBはサーバーと違って壊したら復旧できません。ユーザーの端末にしかデータがないからです。決めたルールは2つだけ。
- マイグレーションは追加のみ(
CREATE TABLE/ALTER TABLE ADD COLUMN)。DROP・型変更・カラム削除は禁止 -
PRAGMA user_versionで管理し、スキーマ適用とバージョン更新を同一トランザクションに入れる
ランナー
これで全部です。
export interface Migration {
/** 適用後の user_version(1始まりの連番) */
version: number;
up(db: SqlDatabase): void;
}
export const migrations: readonly Migration[] = [
{ version: 1, up: (db) => db.execBatch(SCHEMA_V1) },
{ version: 2, up: (db) => db.execBatch(SCHEMA_V2) },
// …v9まで末尾に追記していく
];
export const LATEST_VERSION = migrations[migrations.length - 1].version;
/**
* 現在の user_version から list の最新まで順番に適用する。
* (listを引数に取るのは、旧バージョンフィクスチャからの昇格テストのため)
*/
export function runMigrations(db: SqlDatabase, list: readonly Migration[]): void {
if (list.length === 0) return;
const latest = list[list.length - 1].version;
let current = db.getUserVersion();
// アプリより新しいDB = ダウングレード。触らずに落とす(データ保護)
if (current > latest) throw new SchemaTooNewError(current, latest);
for (const m of list) {
if (m.version <= current) continue;
if (m.version !== current + 1) {
throw new Error(
`migration versions must be sequential: expected ${current + 1}, got ${m.version}`,
);
}
db.transaction(() => {
m.up(db);
db.setUserVersion(m.version); // ← 同一トランザクション
});
current = m.version;
}
}
/** アプリの全マイグレーションを適用する(起動時に呼ぶ) */
export function migrate(db: SqlDatabase): void {
runMigrations(db, migrations);
}
設計上の要点は3つです。
-
runMigrations(db, list)が list を引数に取る。 これがないと「v3までしか当たっていないDB」をテストで作れません。migrate()は薄いラッパにしておく -
current > latestは例外で落とす。 新しいアプリを入れた後に古いバージョンへ戻した(TestFlightの旧ビルドに戻る等)ケースです。ここで黙って動かすと、知らないカラムを無視したまま書き込んでデータを壊します - 連番検証を入れる。 ブランチを2本並行させると「v8が2つある」が普通に起きます。起動時に落ちれば気付けます
個々のマイグレーションはSQL文字列1つ
v5(ジャンルと優先度の追加)はこれで全部です。
export const SCHEMA_V5 = `
CREATE TABLE genres (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
ALTER TABLE quests ADD COLUMN priority INTEGER NOT NULL DEFAULT 2;
ALTER TABLE quests ADD COLUMN genre_id TEXT REFERENCES genres(id);
`;
NOT NULL DEFAULT を付ける列と、nullableにする列の線引き
既定値が決まる列にだけ NOT NULL + DEFAULT を付けます。 上の priority のように既定値を書けば既存行がその値で自動的に埋まるので、アプリ側に「古い行はnullかもしれない」分岐が生えません。
逆に、外部キーや後から任意入力になる列は素直に nullable にします。SQLite は ALTER TABLE ... ADD COLUMN で NOT NULL を使うなら非NULLの DEFAULT が必須で、genre_id のようなFK列には付けようがないからです。
このアプリの ADD COLUMN は11本ありますが、うち4本(repeat_every_days / folder_id / genre_id / completed_on)は nullable のままです。nullable にした列は、
- 型定義に
string | nullを明示する - 既定値へ畳む場所をクエリ関数の行マッパー1箇所に固定する
これを守ると、9世代積んでも画面側のコードに null チェックが漏れ出しません。
データのバックフィルも同じ枠でやる
「追加のみ」は既存行のUPDATEを禁じるものではありません。v6では、列を足したあと同じSQLで埋めています。
export const SCHEMA_V6 = `
ALTER TABLE exp_events ADD COLUMN completed_on TEXT;
UPDATE exp_events SET completed_on = (
SELECT c.completed_on FROM quest_completions c WHERE c.id = exp_events.ref_id
) WHERE source = 'quest';
`;
同一トランザクションなので、途中で落ちても「列だけあって中身が空」にはなりません。
昇格テストを書く
ここが本番です。**「旧バージョンのDBに残りを当てて、新規インストールと同じ形になるか」**をCIで見張っていれば、リリース後にスキーマ差分で事故ることはまずありません。
test('user_version 0 の空DBが最新バージョンまで昇格する', () => {
const db = createEmptyDb();
expect(db.getUserVersion()).toBe(0);
migrate(db);
expect(db.getUserVersion()).toBe(LATEST_VERSION);
expect(tableNames(db)).toEqual([
'ad_boosts', 'app_settings', 'exp_events', 'folders', 'genres',
'player', 'quest_completions', 'quests', 'skill_uses',
]); // 到達スキーマを固定する
});
test('v1時代のクエスト行が最新まで昇格しても保全され、新カラムはデフォルト値になる', () => {
const db = createEmptyDb();
runMigrations(db, [migrations[0]]); // 旧アプリ相当: v1のみ適用
// v1スキーマ(repeat_every_days / due_has_time なし)でデータを投入
db.run(
`INSERT INTO quests (id, title, difficulty, due_at, repeat_type, repeat_days,
sort_order, created_at, updated_at)
VALUES ('q-v1', 'v1時代のクエスト', 2, 5000, 'weekly', 5, 0, 1000, 1000)`,
);
runMigrations(db, migrations); // 新アプリ相当: 最新まで適用
const row = db.getFirst<{
title: string; due_at: number; due_has_time: number; repeat_every_days: number | null;
}>("SELECT title, due_at, due_has_time, repeat_every_days FROM quests WHERE id = 'q-v1'");
expect(row?.title).toBe('v1時代のクエスト');
expect(row?.due_at).toBe(5000);
expect(row?.due_has_time).toBe(0); // v3のデフォルト値
expect(row?.repeat_every_days).toBeNull(); // v2はnullable
});
「INSERTしたときにはまだ存在しなかった列」を、昇格後に読んで検証しているのが要点です。runMigrations(db, migrations.slice(0, 6)) のように書けば、任意の世代のDBが作れます。
そして、入れておくと一番安心するのがこの2本です。
test('バージョンが飛んでいるとエラー', () => {
const db = createEmptyDb();
const skipped: Migration = { version: LATEST_VERSION + 2, up: () => undefined };
expect(() => runMigrations(db, [...migrations, skipped])).toThrow(/sequential/);
});
test('マイグレーション失敗時はロールバックされ user_version も進まない', () => {
const db = createEmptyDb();
const broken: Migration = {
version: LATEST_VERSION + 1,
up: (d) => {
d.execBatch('CREATE TABLE will_rollback (a INTEGER)');
throw new Error('boom');
},
};
runMigrations(db, migrations);
expect(() => runMigrations(db, [...migrations, broken])).toThrow('boom');
expect(db.getUserVersion()).toBe(LATEST_VERSION); // 進んでいない
expect(tableNames(db)).not.toContain('will_rollback'); // 消えている
});
後者は「トランザクションで括る」という設計判断そのものをテストにしたものです。ここが壊れると、ユーザーの端末は user_version だけ上がっていて中身が古い という復旧不能な状態になります。
最悪なのは「user_version だけ上がってSQLが失敗した」状態です。以降のマイグレーションは「もう適用済み」と判断してスキップされ、アプリは存在しない列を読みにいきます。トランザクションで括るのは必須です。
実行環境と同じSQLiteでテストする
テストは Node 上の SQLite で回しますが、外部キー制約が実効しているかは必ず確認しておきます。PRAGMA foreign_keys は接続ごとの設定で、デフォルトOFFの環境があるためです。
test('外部キー制約が実効する(存在しないquest_idの完了記録はINSERTできない)', () => {
const db = createTestDb();
expect(() =>
db.run(
`INSERT INTO quest_completions (id, quest_id, completed_on, completed_at, exp_gained)
VALUES ('c-1', 'no-such-quest', '2026-07-16', 0, 10)`,
),
).toThrow(/FOREIGN KEY/i);
});
これが通らないなら、テストは「FKが効いていないDB」を検証していることになります。
③ 時刻は Date.now() で取らず、引数で注入する
ストリーク(連続達成日数)、期限判定、繰り返しの次回出現日、広告ブーストの残り時間……時間依存のロジックが山ほどありました。方針はひとつ、ロジック層から Date.now() を全部追い出す。
// ✕ テストのたびにシステム時刻をモックする羽目になる
export function startAdBoost(db: SqlDatabase): StartAdBoostResult
// ○ 呼び出し側が時刻を決める
export function startAdBoost(db: SqlDatabase, todayStr: string, now: number): StartAdBoostResult
export function recalcPlayer(db: SqlDatabase, today: string, now: number): PlayerRow
export function createQuest(db: SqlDatabase, input: CreateQuestInput, now: number): QuestRow
Date.now() を呼ぶのは画面コンポーネントの入口と、一部のサービス層(課金キャッシュの有効期限判定など、外部SDKの応答時刻をそのまま扱う場所)だけ。src/game と src/db からは完全に追い出しました。
実際、grep して残るのはコメント1件だけです。
$ grep -rn "Date.now()" src/game src/db --include="*.ts" | grep -v __tests__
src/game/stats.ts:5: * (Date.now()を内部で呼ばない — クエリ関数と同じテスト容易性の流儀)。
日付境界は「文字列」で扱う
日時は unix ミリ秒(INTEGER)で統一していますが、日付境界を扱う関数だけは端末ローカル日付文字列 'YYYY-MM-DD' を引数に取ります。
/**
* - 今日完了済みなら「…前々日・前日・今日」と連続する日数
* - 今日未完了でも、昨日まで連続していればその日数を維持(今日はまだチャンスがあるため)
* - 昨日も今日も完了がなければ 0(ストリーク途切れ)
*/
export function calcStreak(completedDates: Iterable<string>, today: string): number {
const days = new Set(completedDates);
if (days.size === 0) return 0;
const anchor = days.has(today) ? today : addDays(today, -1);
if (!days.has(anchor)) return 0;
let streak = 0;
let cursor = anchor;
while (days.has(cursor)) {
streak++;
cursor = addDays(cursor, -1);
}
return streak;
}
この関数はDBもタイムゾーンも時計も知らない、ただの集合演算です。だからテストは文字列を並べるだけになります。
const TODAY = '2026-07-16';
test('今日未完了でも昨日まで連続していれば維持 → 3', () => {
expect(calcStreak(['2026-07-13', '2026-07-14', '2026-07-15'], TODAY)).toBe(3);
});
test('昨日も今日も完了なし → 0(途切れ)', () => {
expect(calcStreak(['2026-07-13', '2026-07-14'], TODAY)).toBe(0);
});
test('途中に穴があると穴以前はカウントしない', () => {
expect(calcStreak(['2026-07-12', '2026-07-14', '2026-07-15', '2026-07-16'], TODAY)).toBe(3);
});
test('同日重複(複数クエスト完了)は1日として数える', () => {
expect(calcStreak(['2026-07-15', '2026-07-15', '2026-07-16', '2026-07-16'], TODAY)).toBe(2);
});
test('年跨ぎの連続', () => {
expect(calcStreak(['2025-12-31', '2026-01-01'], '2026-01-01')).toBe(2);
});
test('未来の日付が混ざっていても今日から遡ってカウントする', () => {
expect(calcStreak(['2026-07-16', '2026-07-20'], TODAY)).toBe(1);
});
「年をまたぐ」「今日サボったが昨日まで30日連続」「未来日付が混ざる」を、セットアップ0行で書けます。jest.useFakeTimers() は最後まで一度も使いませんでした。
日付ヘルパーの落とし穴2つ
落とし穴1: toISOString().slice(0, 10) を使わない
Date#toISOString() は UTC を返します。JST(UTC+9)の午前0時〜午前9時は前日の日付になり、「深夜にタスクを完了したのにストリークが伸びない」という最悪のバグになります。ローカル日付は必ず自前で組み立てます。
/** Dateを端末ローカルの 'YYYY-MM-DD' に変換する */
export function localDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
落とし穴2: 日付の加減算は「UTC正午」を基準にする
'YYYY-MM-DD' に日数を足すとき、ローカルの0時を基準にするとサマータイム(DST)のある地域で1日ずれます。DSTの切り替わり日は23時間や25時間になるためです。UTCの正午を基準に86,400,000msを足せば、±1時間動いても日付は変わりません。
/**
* 'YYYY-MM-DD' にdays日を加算する(負数で減算)。
* タイムゾーンの影響を受けないようUTC正午を基準に計算する。
*/
export function addDays(dateStr: string, days: number): string {
const [y, m, d] = parseDate(dateStr);
const t = Date.UTC(y, m - 1, d, 12) + days * 86_400_000;
const nd = new Date(t);
const yy = nd.getUTCFullYear();
const mm = String(nd.getUTCMonth() + 1).padStart(2, '0');
const dd = String(nd.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
/** 「今日のクエスト」抽出の期限境界: 端末ローカルの翌日0時のunixミリ秒 */
export function startOfTomorrowMs(now: Date): number {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
}
'YYYY-MM-DD' ⇄ Date の変換をこの2関数に閉じ込めておくと、タイムゾーン起因のバグは実質ここだけを見ればよくなります。
副産物
時刻を引数にすると、バックアップ復元やデータ移行のときに「過去の時刻」でロジックを再実行できます。EXPを追記イベント(exp_events)で持ち、player.total_exp / level を再計算可能なキャッシュとして扱う設計と相性がよく、「復元後に再計算関数を流す」だけで整合が取れます。
④ サブスクを出すなら「アプリ説明文」にEULAリンクが要る(Guideline 3.1.2)
1回目の却下はこれでした。文面はほぼ定型です。
The submission offers auto-renewable subscriptions but does not include a functional link to the Terms of Use (EULA) in the app's metadata.
アプリ内のペイウォールには規約リンクを置いていました。足りなかったのはストア側のメタデータです。
結論から言うと、カスタムEULAか標準EULAかにかかわらず、App Description(アプリ説明文)にEULAのリンクを直接書くのが確実です。
- App Store Connect の App Information には License Agreement のセクションがありますが、標準EULAを選んだ場合、URLの入力欄はありません(登録できるのはカスタムEULAの本文だけ)
- カスタムEULAをASCに登録済みでも「メタデータに機能するリンクがない」で弾かれ続け、App Description に直接リンクを貼って初めて通った、という報告もあります(Max Mannstein のブログ)
根拠はガイドライン 3.1.2(c) の本文ではなく、そこが参照する Apple Developer Program License Agreement の Schedule 2 と、Appleのサブスクリプション解説ページのこの一文です。
Please note that your app and App Store metadata must include links to your Terms of Use and Privacy Policy.
— https://developer.apple.com/app-store/subscriptions/
条件が付いていません。アプリ内とストアメタデータの両方が要る、と読むのが安全です。
標準EULAのURLは https://www.apple.com/legal/internet-services/itunes/dev/stdeula/。説明文の末尾にこう足しました。
利用規約(EULA): https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
プライバシーポリシー: https://(自分のURL)
同じページはサインアップ画面(=ペイウォール)側の要件も定めていて、
- サブスクリプション名と期間
- その期間に提供される内容
- 更新価格を、はっきり目立つ形で・現地通貨で
- 既存加入者がサインインまたは購入を復元できる導線
が必須です。ここも合わせて埋めておくと二度手間になりません。
この修正はメタデータのみ・再ビルド不要でした。リジェクトの中では一番安いやつです。
おまけ: AIエージェントと組むときに効いた制約
ここだけポエムです。飛ばして構いません。
AIエージェント開発で効いたのは、モデルの賢さではなく制約の設計でした。やったのは3つ。
1. 仕様書を「正」にして、コードより先に書く。 docs/ に5本(要件・画面設計・データ設計・環境構築・デザイントークン)を置き、常設指示ファイルにこう書きました。
実装判断に迷ったら必ず
docs/を参照する。この5文書と矛盾するコードを書かない。矛盾を見つけたら実装せずに指摘する。
大事なのは最後の一文です。エージェントは黙って辻褄を合わせにいくので、「食い違いは実装せず報告」を明文化すると仕様のバグが実装前に浮きます。実際「この繰り返し仕様だと隔週の起点が決まらない」といった指摘が何度か返ってきました。
2. マイルストーンを1つずつ、実機確認を挟む。 M1(初期化)→ M2(DB層)→ … → M8(仕上げ)と8段階に割り、1マイルストーン = 1依頼に固定。加えて「マイルストーンをまたぐ先回り実装をしない」と明記しました。これがないと、動くけれど誰もレビューしていないコードが増えます。
3. 実装役とは別セッションのエージェントにレビューさせる。 書いた本人に「合ってる?」と聞くと、だいたい「合っています」と返ってきます。仕様書とdiffだけ渡して、観点は毎回同じ3つ。
-
docs/の仕様と矛盾していないか - 規約(デザイントークン直書き禁止・i18nハードコード禁止・
any禁止)を破っていないか - テストが「実装の写経」になっていないか
3番目が特に効きます。同一セッションのエージェントは自分の実装をなぞるテストを書きがちなので、「その関数が壊れてもこのテストは落ちない」を別の目で指摘させる。人間のレビューの代替ではなく、人間が見る前のノイズ除去として機能しました。
チェックリスト
ATT(広告を入れる iOS アプリ)
-
AppState.currentState === 'active'を確認してから要求している -
undeterminedを「拒否」と誤認せず、間隔を空けて再試行している -
待機後に
activeから落ちていないか再確認してから要求している - 他の権限ダイアログと同時に出していない
-
NSUserTrackingUsageDescriptionがある - 設定画面に手動導線がある(回答済みなら設定アプリを開くフォールバック付き)
- 審査ノートに操作手順を書き、実機の画面収録を添付した
-
本番の広告ユニットIDで開発ビルドを回していない(
__DEV__でテストIDに強制切替) - EEA配信なら同意フォーム(UMP)を実装した/していないなら配信国から除外した
ローカルDBを持つアプリ
-
マイグレーションは追加のみ(
CREATE TABLE/ADD COLUMN) -
既定値が決まる追加列は
NOT NULL DEFAULT付き。FKや任意入力の列は nullable にし、型に| nullを出して既定値へ畳む場所を1箇所に固定した -
スキーマ適用と
user_version更新が同一トランザクション - 連番検証が入っている(ブランチ合流時の番号衝突を起動時に検知)
- 旧バージョンからの昇格テストがCIで動く
- 失敗時にロールバックされることをテストしている
- アプリより新しいスキーマを踏んだら安全に落ちる(勝手に触らない)
-
時間依存のロジックは
now/todayを引数で受け取る -
toISOString()でローカル日付を作っていない - 日付の加減算がDSTで壊れない
サブスクを出す
- アプリ説明文にEULAリンクとプライバシーポリシーURLがある
- ペイウォールに名称・期間・更新価格・復元導線・規約/ポリシーのリンクが全部ある
おわりに
23日で出せたのはAIが速いからというより、「実装より先に仕様を書く」「一度に一区画しか進めない」という古い規律をAI相手にも適用したからだと思っています。エージェントは指示の空白を勝手に埋めるので、空白を減らす作業がそのまま生産性になりました。
審査の却下は2回とも、コードの質とは無関係な「知っているかどうか」の問題でした。この記事がその分の時間を誰かから削れたなら嬉しいです。
- QuesToDo(iPhone・無料 / アプリ内課金あり): https://apps.apple.com/app/questodo/id6794613228
参考
- Apple「Auto-renewable Subscriptions」: https://developer.apple.com/app-store/subscriptions/
- Apple 標準EULA(Licensed Application End User License Agreement): https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
- App Store Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
- Apple Developer Forums「ATTrackingManager.requestTrackingAuthorization always returns "Not Determined" and prompt is never shown」: https://developer.apple.com/forums/thread/702582
- @samwize「PITFALL: App Tracking Transparency prompt not showing」: https://samwize.com/2021/11/16/pitfall-app-tracking-transparency-prompt-not-showing/
- Max Mannstein「Where to put the EULA for iOS apps implementing subscriptions」: https://maxmannstein.com/blog/where-to-put-the-eula-for-ios-apps-implementing-subscriptions
- SQLite「ALTER TABLE」: https://www.sqlite.org/lang_altertable.html