綴Photo の勉強ノートに戻る
Flutter技術2026.05.14

写真権限を「限定」に変えただけで、綴った記憶の写真が消えたように見える罠 — Flutter photo_manager / AssetEntity.fromId() の null

FlutterDartphoto_manager権限iOSAndroid綴Photo

綴Photo のモバイル版で、ユーザーが綴った記憶(写真とテキストのペア)が、ある条件で連鎖的に壊れる罠を見つけた。

きっかけは Codex の第三者レビュー。「起動時メンテで写真権限が不十分なときに assetId を破壊している」という指摘で、コード自体は数十行のシンプルな処理だったのに、影響範囲は最悪「ユーザーが綴った記憶が全部端末から消えたように見える」レベルだった。

非エンジニア目線で「そもそも写真権限って何種類あるの?」のレベルから書く。


この記事の前提: AI と協働でレビューを回している

この罠は自分一人でコードを読んで見つけたものではない。

開発フローとして、コミットが溜まったら Codex(別の AI)に第三者レビューを走らせて、見つかった指摘を docs/進捗メモ.md に統合する運用にしている。今回の罠も Codex が「これは公開前マスト級」と指摘し、それを Claude Code と一緒にコードを追って「たしかにこれは壊れる」と確認した流れ。バグを見つけた主体はレビューを回す運用であって、自分が読んで気づいたわけではない。


そもそも何が起きてたか

綴Photo のモバイル版は、ユーザーが端末のカメラロールから写真を選んで、その写真に対して「綴り(気持ち + 短いテキスト)」を保存する作りになっています。

実ファイルと権限と行は別の箱。行は端末 SQLite に保存する。端末の写真ライブラリ実ファイルアプリのテーブルではない閉じてもライブラリに残るfromId問い合わせ端末 SQLite Photosasset_id は ID 文字列実ファイルは入らない閉じても残るOS の写真権限フル / 限定 / 拒否 / 未決定アプリの DB には保存しない端末 SQLite Meaningsbody(綴りの本文)asset_id を切っても残る実ファイルはライブラリ。ID は Photos。本文は Meanings。権限は OS。fromId は関所で、置き場所ではない。

assetId は iOS の PHAsset.localIdentifier / Android の MediaStore ID を photo_manager (Flutter で写真ライブラリを扱うライブラリ)経由で取ったものです。

問題のコードはアプリ起動時の「メンテナンス処理」。ユーザーがカメラロールから写真を削除した場合に、写真との結びつきだけ切るための処理です。

ざっくり書くとこんな感じだった:

dart
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 になります。

4つの状況が同じ null を返し、同じ列へ書く。本当に削除意図通り権限が拒否意図と違う限定アクセス許可リスト外意図と違う権限が未決定意図と違うfromId は null(問い合わせ。置き場所ではない)端末 SQLite Photos.asset_id を null に書く閉じても残る。結びつきだけ消える。本棚では写真が無い記憶に見える。本文は Meanings に残る。権限は OS が持つ。アプリの DB には保存しない。

壊れるシナリオを具体的に追う

実害があるのは、過去にフルアクセスで使ってた → 後で限定アクセスに切り替えた人。

  1. ユーザーがフルアクセスで A・B・C・D の写真を綴る → DB に assetId が 4 件
  2. 設定アプリから「写真は A だけ許可」に切り替えます
  3. 次回アプリ起動 → メンテ処理が走ります
  4. fromId(A) は通ります(許可リスト内)
  5. fromId(B) fromId(C) fromId(D) は null(許可リスト外)
  6. B / C / D の assetId が null 化されます
  7. 本棚を開くと B / C / D は「写真が無い記憶」になっています

ユーザーは「あれ?昔綴ったやつの写真、消えてる…」と感じます。写真とのペアが壊れているので、アプリ的には致命傷です。



修正: フルアクセスのときだけメンテを走らせる

対策は素直に「権限がフルアクセスじゃなかったらメンテをスキップする」。

ただし注意点として、起動時に 権限ダイアログを勝手に出すのは UX が悪いです。ユーザーが何のアクションも起こしていないのに、いきなり「写真へのアクセスを許可してください」が出るとびっくりします。

photo_manager には getPermissionState() という、今の権限状態を確認するだけでダイアログを出さない API があります。これを使う。

dart
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 から復元したとき。

機種変更で iCloud 復元したあと。写真はある。ID は別物。新端末 SQLite Photosasset_id = ABC-123旧端末の行が残る閉じても残る新端末の写真ライブラリ実ファイルの ID は XYZ-789アプリのテーブルではない写真自体はあるOS の写真権限authorized(フル)アプリの DB には保存しないガードはメンテを通すfromId(ABC-123) は null。Photos.asset_id を切る。権限では止まらない。紡Book クラウド DB(サーバ)写真も記憶もサーバ保存。機種が変わっても残る。モバイルの Photos.asset_id とは別の箱。ここには書かない。永続の正はサーバ。端末の asset_id は端末固有で、復元では別の ID になる。

なぜモバイル側で完全には解決しないことにしたか

しばらく考えて、これは モバイル単独では現実的に解決できない問題 だと判断した。

  • 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 は変化することがあるので、最新の公式ドキュメントも合わせて参照してください。