個人で作っている iOS アプリを TestFlight に出しました。
そこで手が止まりました。それまでの検証を、全部 __DEV__ でやっていたからです。
60 秒で開封、匿名ログイン、サンプルの録画。
つまり 本番だけを通る経路は、一度も実行していませんでした。
実機を持っていないので、できる最大のことは、その経路をコードで洗うことだと考えました。
結果、8 件見つかりました。全部、本番でしか起きないものでした。
結論を先に書きます。開発環境が「都合よく」してくれているところを数え上げると、
そこに欠陥が並んでいます。 バグを探すより、都合のよさを探すほうが速い。
60 秒で開封していたので、通知が2通飛ぶのが見えなかった
このアプリの通知は 7 日後に届きます。仕組みはこうでした。
- 作成時に通知タスクを登録する
- 毎日 03:00 の一括処理が、まだ登録されていないものを拾って登録する
作成時の処理が「登録した」という印(notificationScheduled)を書いていませんでした。
7 日後のカプセルは一括処理の条件に完全に一致するので、両方から登録され、
同じ時刻に通知が 2 通飛びます。
開発では 60 秒で開けていました。開くと条件から外れるので、
03:00 の一括処理に到達したことが一度もなかった。 だから見えなかった。
登録したら印を書いて、所有権を主張するようにしました。
シミュレータに通知が来ないので、主経路が抜けているのに気付かなかった
通知タップの購読が onNotificationOpenedApp だけでした。
これは アプリがバックグラウンドで生きているときにしか発火しません。
7 日後に届く通知です。その頃アプリはほぼ確実に終了しています。
つまり主経路がまるごと抜けていました。
export async function consumeLaunchNotification(): Promise<string | null> {
return capsuleIdOf(await getInitialNotification(messaging));
}
同じ「シミュレータに通知が来ない」が、もう1件隠していました。
権限を許可した直後に、誰もデバイストークンを登録していなかった。
- 起動時に登録を試みる → 権限が無いので失敗(
.catch(() => {})で握り潰し) - ユーザーが撮影 → 権限を要求 → 許可
- ここで誰も登録しない
- 次回起動まで、サーバーはこの端末を知らない
初回だけは 24 時間後に開きます。上の窓に丸ごと入ります。
初回の通知は製品にとって最も重要な一撃なのに、届かない可能性が高い状態でした。
失敗を全部「無効なトークン」として消していた
// 修正前
const deadTokens = response.responses
.map((result, index) => (result.success ? null : tokens[index]))
FCM の失敗には一時的なものが多く含まれます。internal-error、server-unavailable、
ネットワーク断、クォータ。それらでも有効なトークンが消えます。
トークンの再登録は次回起動時です。週に1度しか開かないアプリで、
「通知が来ないからアプリを開かない」に入ると、二度と復旧しません。
ループが静かに終わります。
本当に無効なコードのときだけ消すようにしました。
const DEAD_TOKEN_CODES = [
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
];
匿名ログインが、サインインし直したときの破壊を隠していた
ユーザードキュメントの upsert が hasCompletedFirstCapsule: false を
毎回無条件で書いていました。
一度サインアウトして入り直すと(再インストールでも同じ)、
次が 7 日ではなく 24 時間で開きます。 週次というリズムそのものが壊れます。
開発では匿名ログインのたびに uid が変わるので、既存ドキュメントに当たりません。
だから一度も踏みませんでした。
回線が常に生きていたので、「撮ったのに消えた」が見えなかった
未送信件数を返す関数は実装されていました。どこからも呼ばれていませんでした。
電波の無い場所で撮ると、こうなります。
- アップロードが失敗する → Firestore にドキュメントが無い
- ホームはそのコレクションを見ている → 「まだ何も撮っていない」表示になる
- ユーザーからは 「撮ったのに消えた」 ようにしか見えない
テスターは電車の中でも使います。これは例外ではなく通常の経路です。
ホームに出す文言はこうしました。
3件、まだ送れていません。
電波のあるところで自動的に送ります。消えてはいません。
「消えてはいません」を明示したのは、この状況でユーザーが本当に知りたいのが
そこだけだからです。
権限が最初から通っていたので、一度拒否した人が詰むのを踏まなかった
iOS は システムの許可ダイアログを一度しか出しません。
一度「許可しない」を選んだ後に権限を要求しても、ダイアログは出ず、
granted: false が即座に返るだけです。
修正前の画面は「許可する」ボタンを出していました。押しても何も起きません。
回答画面には閉じる導線を置いていない(意図的に)ので、
マイクを拒否した人は答えられません。 回答率がそのまま死ぬ経路でした。
canAskAgain を見て、聞き直せない状態なら「設定を開く」に切り替えました。
const exhausted =
(mode === 'video' && cameraPermission && !cameraPermission.canAskAgain) ||
(microphonePermission && !microphonePermission.canAskAgain);
シミュレータでは許可が通ってしまうため、一度も通らなかった分岐です。
公開機能がまだ無いので、ルールの穴が表面化しなかった
Firestore のルールで、更新条件が緩いところがありました。
allow update: if isOwner(resource.data.uid); // 修正前
所有者判定が見ているのは 既存ドキュメント の uid です。
リクエストで uid を他人のものに書き換えても、古い uid は自分なので、この条件は通ります。
もう1つ、読み取り条件が visibility == 'public' && moderationStatus == 'ok' なのに、
更新が自由でした。所有者が 同じ書き込みで両方を立てられます。
いまは公開フィードの UI が無いので表面化しません。
公開機能を作った瞬間に、審査が自己承認で回避されます。
モデレーションは、モデレートされる側が判定を書けないときにだけ意味を持ちます。
allow update: if isOwner(resource.data.uid)
&& request.resource.data.uid == resource.data.uid
&& request.resource.data.moderationStatus == resource.data.moderationStatus;
直さなかったものもあります
| 事象 | 判断 |
|---|---|
createdAt を固定していない |
「N日前」を詐称できるが、被害者は本人だけ |
status を自由に変えられる |
同上 |
| 本人は開封前でも自分のメディアを読める | ルールに既知として明記済み |
いずれも 自分の体験を自分で壊せるだけで、他人に影響しません。
公開機能を作るときに再評価します。
まとめ
見つかった 8 件は、すべて開発環境の性質そのものによって隠されていました。
| 隠していたもの | 隠れた欠陥 |
|---|---|
| 60 秒で開封 | 通知の二重送信(03:00 の一括処理に到達しない) |
| シミュレータに通知が来ない | 終了状態からのタップ、権限直後の未登録 |
| 匿名ログイン(毎回新規 uid) | サインインし直しで初回状態に戻る |
| 常時オンの安定した回線 | アップロード失敗が不可視 |
| 権限が最初から通っている | 一度拒否した人が二度と撮影・録音できない |
| 公開フィードがまだ無い | uid とモデレーション判定を書き換えられる |
- 「開発で動いた」は「本番で動く」の根拠になりません
- 探すべきはバグではなく、開発環境が都合よくしているところ
- 都合のよさを1行ずつ数え上げると、その隣に欠陥が並んでいます
- 一時的な失敗と恒久的な失敗を同じ扱いにすると、復旧経路が消えます
- 失敗は画面に出す。 出ていないものは、ユーザーからは「消えた」と同じです
踏んだ落とし穴だけを書いています。 同じところで止まった人の時間が少しでも減れば十分です。
他の記事も同じ形で、実際に測った数字と、直した手順だけを置いています。
(この話が出てきたのは Wisp という個人開発のアプリです)