0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

開発環境が便利にしていたところに、バグが8件並んでいた

0
Last updated at Posted at 2026-08-14

個人で作っている 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件隠していました。
権限を許可した直後に、誰もデバイストークンを登録していなかった。

  1. 起動時に登録を試みる → 権限が無いので失敗(.catch(() => {}) で握り潰し)
  2. ユーザーが撮影 → 権限を要求 → 許可
  3. ここで誰も登録しない
  4. 次回起動まで、サーバーはこの端末を知らない

初回だけは 24 時間後に開きます。上の窓に丸ごと入ります。
初回の通知は製品にとって最も重要な一撃なのに、届かない可能性が高い状態でした。

失敗を全部「無効なトークン」として消していた

// 修正前
const deadTokens = response.responses
  .map((result, index) => (result.success ? null : tokens[index]))

FCM の失敗には一時的なものが多く含まれます。internal-errorserver-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 という個人開発のアプリです)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?