綴Photo の勉強ノートに戻る
プロダクト設計2026.07.28

画面で約束していることを、支えていたのは他人のライブラリの実装詳細だった

プライバシーEXIFFlutter設計綴Photo

AIレビューで「アップロードの出口が2本ある」と指摘されて中を見たら、漏れてはいなかったが漏れていない理由が自分のコードの外にあった、という記録です。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

写真を web に公開する時、確認画面にこう出しています。

小さくした写真(位置情報・カメラの情報は取り除いてから)

家の写真を公開するアプリなので、この一行はかなり重い約束をしている。

剥がす処理はちゃんと書いてあった

該当の関数はこう。EXIF を消すところに、わざわざ理由まで書いてあります。

dart
// EXIF(GPS・撮影機材・撮影日時など)を必ず剥がす。再エンコードだけでは消えない
// =image パッケージは decodeImage→encodeJpg でメタデータを round-trip させるため、
// 縮小しない小さい画像はそのまま位置情報まで web へ同梱されてしまう(テストで実証)。
// 先に bakeOrientation で回転を画素へ焼いてから消す。向きの情報も EXIF なので、
// 消すだけだと縦で撮った写真がアップ後に横向きで出る。
final out = img.bakeOrientation(resized);
out.exif = img.ExifData();                              // ← 付箋を空にして貼り直す
return Uint8List.fromList(img.encodeJpg(out, quality: quality));

テストもあります。GPS を埋めた JPEG を作って、通した後に EXIF が空であることを確かめています。

問題はここではない。この関数を呼んでいる場所だった。

dart
Future<Uint8List?> resolveJpegBytes(Photo photo) async {
  if (PlatformUtils.isDesktopOS) {
    // ...ファイルを読んで...
    return resizeToJpeg(await file.readAsBytes());        // ← 剥がす
  }
  // スマホ側
  final asset = await AssetEntity.fromId(id);
  return asset.thumbnailDataWithSize(                      // ← 剥がさない
    const ThumbnailSize(1920, 1920), quality: 88);
}

デスクトップは剥がす処理を通っています。スマホは通っていません。

漏れていない理由が、リポジトリの外にあった

では今スマホで公開すると位置情報が出るのか。調べたところ、出ません。

JPEG は絵柄を圧縮した箱のようなもので、EXIF は箱の外に貼った付箋にあたります。開いて画素(点の並び)だけ取り出し、新しい箱に詰め直せば、付箋は普通ついてきません=消えます。上のコードでわざわざ空にしているのは、使っている image パッケージが親切に付箋を貼り替えるからで、その経緯は 「再エンコードすれば位置情報は落ちる」はライブラリ次第で成り立たない — 思い込みをテストで縛る に書いた。

写真を取ってくるライブラリ(photo_manager)の中まで見ないと分からないので、そのまま AI に辿ってもらった。thumbnailDataWithSize は、iOS では UIImageJPEGRepresentation、Android では Glide でビットマップに展開してから compress しています。どちらも一度画素にほどいてから作り直しているので、結果に EXIF は乗りません。

EXIF は JPEG の中(画素の外)。端末 SQLite の Photos には乗らない。送るバイトはメモリ。保存しない。デスクトップディスク上のファイルJPEG + EXIF(GPS)。閉じても残るスマホ端末の写真ライブラリJPEG + EXIF(GPS)。アプリの外自分のリポジトリ剥がしてから返すリポジトリの中。テストもあるphoto_manager の中画素にほどいて作り直すリポジトリの外。仕様ではないメモリ(保存しない)送る JPEG。EXIF 無し。自分が剥がしたメモリ(保存しない)送る JPEG。EXIF 無し。外で落ちたweb に公開。届くのはメモリ上の JPEG。元ファイルはその場
  • 自分のリポジトリには、スマホ側で剥がしている行がありません
  • そのことを説明したコメントもありません
  • スマホ経路を通したテストもありません

支えているのは、第三者ライブラリが今そう実装しているという事実だけだった。

これが崩れる形はいくつもあります。

  • 「サムネイルだと画質が足りない」で originBytes(元ファイルそのもの)に差し替えます
  • ライブラリを上げて、内部の取り出し方が変わります
  • 後日の自分か AI が「デスクトップだけ余計な処理をしている」と読んで、揃えるつもりで剥がすほうを消します

どれも悪意なく起きるし、起きた日に確認画面の文言は何も変わりません。約束だけが静かに嘘になります。

もうひとつ効いたのは、デスクトップだけ守られている非対称そのもの。次にこのコードを開く自分(と、実装を任せている AI)は、片方に剥がす処理があるのを見て「両方守られている」と読む。実際には守られていない側があるとは思わない。

出口を1つにした

読むところと剥がすところを分けて、返り値が必ず剥がしを通るようにした。

dart
@override
Future<Uint8List?> resolveJpegBytes(Photo photo) async {
  final raw = await _readRawBytes(photo);
  if (raw == null) return null;
  return resizeToJpeg(raw);              // ← 出口はここだけ
}
 
/// 端末から生バイトを読むだけ。ここから返る値は必ず未処理=剥がしは呼び出し元。
Future<Uint8List?> _readRawBytes(Photo photo) async {
  if (PlatformUtils.isDesktopOS) { /* ファイルを読む */ }
  /* サムネイルを取る */
}
画素も EXIF も端末 SQLite の Photos には乗らない。読むバイトも送るバイトもメモリ。保存しない。デスクトップディスク上のファイルスマホ端末の写真ライブラリメモリ:生バイト(未処理)読むだけ。ここから返しても剥がしていない。保存しない剥がし(出口はここだけ)メモリ:送る JPEGEXIF 無し。web へ。保存しない送らない剥がせなければ null。web に出ない

元のコードは「読む」と「加工」をまとめて if の中に入れていた。分岐の中に、分岐する必要のないものが混ざっていた。

追加のコストはデコードと再エンコードが1回ずつ。長辺1920への縮小そのものはサムネイル側で既に効いているので、リサイズの手間は増えません(速度は測っていない)。

剥がせなかったら null を返す(=その写真を送らない)ようにもした。約束を守れないバイトを送るくらいなら、送らないほうを選ぶ。

払っているものもあります。null を返した写真は「送っていない」扱いのまま残るので、原因が消えるまで次の同期でも毎回試して毎回落ちます。そのぶん、約束が破れたまま web に出ることはありません。

テストが「関数の中身」しか見ていなかった

既存のテストは resizeToJpeg に GPS 入り画像を渡して、出てきたものに EXIF が無いことを確かめていた。これは関数の中身の検証で、「その関数を実際に通っているか」は何も言っていません。実際、通っていない経路があったのにテストは通っていた。

なので、出口そのものを測るテストに変えた。

dart
final out = await const DefaultPhotoBytesResolver()      // resolver の実体を作る
    .resolveJpegBytes(await insertPhotoAt(file.path));   // ← 出口の戻り値そのものを取る
 
// expect(調べたい値, 期待する結果) の形で書く
expect(out, isNotNull);                                   // out が null でないことを期待
final outExif = img.decodeJpgExif(out!);                 // 出てきた JPEG から付箋を読む
expect(outExif == null || outExif.isEmpty, isTrue,       // 付箋ごと無い / 箱はあるが空、どちらもOK
    reason: 'resolver の戻り値に EXIF が残ってはいけない');  // 落ちた時に出る一言

デスクトップ経路はこれで通せます。プラットフォーム判定にテスト用の差し替え口があるので、実際にファイルを書いて、入口から出口まで通しで確かめられます。

スマホ経路は通せません。 カメラロールを見に行くので、テストの中には存在しません。ここは正直に諦めて、代わりに構造をソースで固定した。

dart
test('生バイトを読む口は _readRawBytes だけで、出口は resizeToJpeg 1本', () {
  final source = File('lib/core/upload/photo_bytes_resolver.dart')  // 動かす相手ではなく、読む相手として開く
      .readAsLinesSync()                                   // ファイルを1行ずつの配列にする
      .where((line) => !line.trimLeft().startsWith('//'))  // コメント行を捨てる(doc の経緯文に API 名が出てくるので)
      .join('\n');                                         // 残りを1本の文字列に戻す。以降はこれを検索する
 
  final readerIndex = source.indexOf('_readRawBytes(Photo photo)');  // 読み取り関数が先頭から何文字目に出るか
  expect(readerIndex, greaterThan(0),                      // 0 より大きい=見つかった(無ければ -1 が返る)
      reason: '読み取り関数の名前が変わった');               // 落ちた時に出る一言。名前が変わった合図
 
  for (final api in ['thumbnailDataWithSize', 'readAsBytes']) {  // 端末から生バイトを読む API は この2つ
    expect(source.indexOf(api), greaterThan(readerIndex),  // その API が読み取り関数より「後ろ」にしか無い
        reason: '$api が _readRawBytes の外に出ている=剥がしを迂回する経路ができた');  // 前に出たらこれ
  }
 
  expect('resizeToJpeg('.allMatches(source).length, 1,     // 剥がしの呼び出しがファイル全体で何回あるか
      reason: '剥がしの呼び出しが増えた=出口が1本でなくなった');  // 1 でなければ出口が増えている
});

美しくはないが、「読む API が1つの関数の中にしか出てこない」を機械が見張ります。試しに迂回を復活させたら、このファイルのテスト4本のうち3本が落ちたので、少なくとも空振りではない。

学び

  • ユーザーへの約束は、それを支えているものが自分のコードの中にあるか確かめる。バグが無いことと、バグが起きない仕組みがあることは別です
  • プラットフォーム分岐には、本当に違うものだけを入れる。違わない処理まで if の中に入れると、片側に書き忘れても分岐に隠れて見えません
  • 守りたいのが「出力に混ざらないこと」なら、測るのは出口。関数の中身を検証するテストは、その関数を通っている証拠になりません
  • 測れない経路は、測れないと書いて別の守り方を足す。通せないからテスト無し、にすると次は誰も気づきません

コードのコメントには、photo_manager が今どう実装しているかまで書き残した。次に「サムネイルだと画質が足りない」と思うのはたぶん自分なので、その一行の前で止まれるようにした。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。