綴Photo のモバイル版で、ユーザーが綴った記憶(写真とテキストのペア)が、ある条件で連鎖的に壊れる罠を見つけた。
きっかけは Codex の第三者レビュー。「起動時メンテで写真権限が不十分なときに assetId を破壊している」という指摘で、コード自体は数十行のシンプルな処理だったのに、影響範囲は最悪「ユーザーが綴った記憶が全部端末から消えたように見える」レベルだった。
非エンジニア目線で「そもそも写真権限って何種類あるの?」のレベルから書く。
この記事の前提: AI と協働でレビューを回している
この罠は自分一人でコードを読んで見つけたものではない。
開発フローとして、コミットが溜まったら Codex(別の AI)に第三者レビューを走らせて、見つかった指摘を docs/進捗メモ.md に統合する運用にしている。今回の罠も Codex が「これは公開前マスト級」と指摘し、それを Claude Code と一緒にコードを追って「たしかにこれは壊れる」と確認した流れ。バグを見つけた主体はレビューを回す運用であって、自分が読んで気づいたわけではない。
そもそも何が起きてたか
綴Photo のモバイル版は、ユーザーが端末のカメラロールから写真を選んで、その写真に対して「綴り(気持ち + 短いテキスト)」を保存する作りになっています。
assetId は iOS の PHAsset.localIdentifier / Android の MediaStore ID を photo_manager (Flutter で写真ライブラリを扱うライブラリ)経由で取ったものです。
問題のコードはアプリ起動時の「メンテナンス処理」。ユーザーがカメラロールから写真を削除した場合に、写真との結びつきだけ切るための処理です。
ざっくり書くとこんな感じだった:
Future<void> nullifyDeletedCameraRollPhotos(AppDatabase db) async {
final photosWithAsset = await db.photoDao.fetchPhotosWithAssetId();
for (final entry in photosWithAsset) {
final asset = await AssetEntity.fromId(entry.assetId);
if (asset == null) {
await db.photoDao.nullifyAssetId(entry.id); // ← assetId を null に
}
}
}シンプルな処理に見える。「fromId が null を返す = カメラロールから消えた = リンクを切る」というロジック。
何が罠か: fromId が null を返す条件は「削除」だけじゃない
AssetEntity.fromId() が null を返すのは、実際にその写真が削除されたとき だけじゃありません。写真ライブラリにアクセスできていないとき も null になります。
壊れるシナリオを具体的に追う
実害があるのは、過去にフルアクセスで使ってた → 後で限定アクセスに切り替えた人。
- ユーザーがフルアクセスで A・B・C・D の写真を綴る → DB に assetId が 4 件
- 設定アプリから「写真は A だけ許可」に切り替えます
- 次回アプリ起動 → メンテ処理が走ります
fromId(A)は通ります(許可リスト内)fromId(B)fromId(C)fromId(D)は null(許可リスト外)- B / C / D の
assetIdが null 化されます - 本棚を開くと B / C / D は「写真が無い記憶」になっています
ユーザーは「あれ?昔綴ったやつの写真、消えてる…」と感じます。写真とのペアが壊れているので、アプリ的には致命傷です。
修正: フルアクセスのときだけメンテを走らせる
対策は素直に「権限がフルアクセスじゃなかったらメンテをスキップする」。
ただし注意点として、起動時に 権限ダイアログを勝手に出すのは UX が悪いです。ユーザーが何のアクションも起こしていないのに、いきなり「写真へのアクセスを許可してください」が出るとびっくりします。
photo_manager には getPermissionState() という、今の権限状態を確認するだけでダイアログを出さない API があります。これを使う。
Future<void> nullifyDeletedCameraRollPhotos(AppDatabase db) async {
// 写真権限が「フルアクセス」のときだけ実行する。
// 限定アクセス/拒否では AssetEntity.fromId() が null を返す → 実存する写真でも
// 「削除された」と誤判定して assetId を破壊してしまうため。
// 起動時に勝手に権限ダイアログを出さないよう、requestPermissionExtend ではなく
// getPermissionState で現在状態のみ確認する。
final state = await PhotoManager.getPermissionState(
requestOption: const PermissionRequestOption(),
);
if (state != PermissionState.authorized) {
return;
}
// 以下、フルアクセス時のみ走る既存処理
final photosWithAsset = await db.photoDao.fetchPhotosWithAssetId();
for (final entry in photosWithAsset) {
final asset = await AssetEntity.fromId(entry.assetId);
if (asset == null) {
await db.photoDao.nullifyAssetId(entry.id);
}
}
}state != PermissionState.authorized で notDetermined / denied / restricted / limited 全部を除外しています。
ただし「機種変更したらどうなるの?」問題は残る
実はこの修正、完全には救えてない。
AssetEntity.fromId の null 罠で、もうひとつ厄介なケースがあります。機種変更で iCloud から復元したとき。
なぜモバイル側で完全には解決しないことにしたか
しばらく考えて、これは モバイル単独では現実的に解決できない問題 だと判断した。
assetIdは端末固有 ID なので、機種変更したら必ず別物になります- iOS 16+ で、写真をクラウド経由で同定するためのマッピング API(
PHCloudIdentifierを使った変換)が追加されたが、iCloud Photos 有効ユーザー限定で完全じゃありません - 撮影日時 + ファイルサイズ + ハッシュで再マッチングする UI を作るのも実装コストが見合いません
代わりに、綴Photo は 紡Book(tsuzuphoto.com)を「永続保存の母艦」と位置づける プロダクト戦略にしています。モバイル側は「日々綴る入り口」、本当に残したい記憶は Web で永続化、という棲み分け。
なので今回のモバイル修正は「日常使用での誤破壊を止める」のがスコープ。機種変更の破壊は別軸で、UI 側に「この端末にだけ保存されています」と注意喚起を入れる方向で詰める。
学んだこと
- 「API が null を返す = 想定の意味だ」と思ったら危ないです。null を返す条件は API 仕様を読まないとわからないし、権限まわりは特に「想定外の null」が出やすいです
- 写真権限は二値(許可 / 拒否)じゃありません。限定アクセスという中間状態があることを知っておく
- 起動時の処理で 権限ダイアログを勝手に出さない ためには、
requestPermissionExtendではなくgetPermissionStateを使います(これは photo_manager 固有の話だけど、同じ発想は他の権限ライブラリでも使えます) - 端末固有 ID への依存は機種変更で必ず破綻します。これはモバイルアプリ全般の構造的限界。永続化を保証したいならクラウド DB を持つしかありません
おわりに
このバグは、ユーザーが何もしなくても「写真権限を変えた」だけで進行する性質のものだった。リリース済みのアプリだと誰かの記憶が静かに壊れる可能性があり、公開前に止められた。
第三者レビュー(Codex)を回す運用は手間がかかりますが、こういう自分一人だと気づけない盲点を拾えます。
この記事は2026年5月時点の自分の理解で書いています。photo_manager の API は変化することがあるので、最新の公式ドキュメントも合わせて参照してください。