Skip to content

fix(chat): read an image data URL's type from its header, not by copying it - #413

Merged
yuga-hashimoto merged 1 commit into
mainfrom
fix-oom-large-image-payloads
Oct 30, 2026
Merged

yuga-hashimoto merged 1 commit into
mainfrom
fix-oom-large-image-payloads

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Fixes the Crashlytics OOM in ChatViewModel.imageMime (v1.2.27, 3 events / 2 users): String.toLowerCase failed to allocate 76 MB while converting a file part whose URL is a large data: URL.

Finding the MIME type of a data: URL copied its payload, up to twice per call:

  • parseDataImageUri removed the data: prefix (a full copy) and then took the payload after the comma (a second copy), only for imageMime to keep the mime.
  • When it returned null (a part whose declared type is not an image, e.g. an application/octet-stream or PDF data URL) imageMime fell through to its file-extension fallback, whose lowercase() copied the payload again, after looking for .png inside base64 text. That is the allocation that failed.

toChatPart runs for every part on every transcript load. Parts that declare an image type return before reaching this, so the failing path is non-image data: URLs.

What changed

  • dataImageMime(url: CharSequence) reads only the first 1024 characters and returns the type from the header. Nothing larger than the header is copied.
  • imageMime and the image viewer's fallback type no longer look for a file extension inside a data: URL.
  • parseDataImageUri makes the one copy its callers need (the base64 to decode).
  • Ordinary URLs (https://…/shot.png, /workspace/photo.jpg) are unchanged.

One deliberate behaviour change: the header is now only searched for its comma within the first 1024 characters. The app's own producers (AttachmentImporter, CodexItemParser, OpenCodeV2ApiClient) only build short data:<mime>;base64, headers, so this only affects external URLs with very long parameters; those parts now fall out of the display instead of being decoded. It cannot crash.

What this does not claim

  • This removes the allocation that failed and the copies on the toChatPart path. It does not find why the heap was already nearly full: the 76 MB string is itself resident, largeHeap is already on, and the same payload can be held several times over by the V2 client while a transcript loads. The other OOM issues in Crashlytics (Compose snapshot, coroutines, OEM framework frames) are victims of that same pressure and are not individually fixable here.
  • No device verification.

Not in this PR (known follow-ups)

From the review, none blocking:

  • loadChatImageBytes copies the base64 once and only then checks MAX_IMAGE_BASE64_CHARS, so an oversized URL is rejected after a copy. Checking source.url.length first would reject it with no allocation.
  • CodexItemParser.userInputImagePart still does url.substringAfter("data:", "").substringBefore(';'), which copies a whole data URL on every Codex history read. dataImageMime(url) ?: "image/png" would replace it.
  • decodeImageFromUrlOrPath and decodeDataImage in ChatMessageComponents.kt have no callers anywhere in the repo (checked across all source sets). They decode the whole base64 with no size limit, so they are a trap if reused; worth deleting separately.

Test plan

  • ./gradlew spotlessCheck detekt :app:testGithubDebugUnitTest — 1627 tests, 0 failures. New: DataImageMimeTest (11). It watches how far into the text the header check reads and whether it copies a large span, and it did not compile before the change.
  • ./gradlew :app:assembleGithubDebug :app:lintGithubDebug — 0 errors, no findings in the changed files
  • Not verified on a device.
  • CI green

Pre-PR review

The repo-reviewer subagent is defined for OpenCode (.opencode/agents/repo-reviewer.md) and is not available in the Claude Code session that produced this change. The review below was done by a read-only Claude Code subagent given that definition as its role and output format. It is a stand-in, not the repo-reviewer subagent itself.

It reviewed commit 3f812d16. The amended HEAD differs from it only in KDoc and commit-message wording (the reviewer found the original claimed "three copies" and that every image part paid the cost, which overstated it; both are corrected above) and in three tests the reviewer asked for. No production logic differs.

Pre-PR review

判定: APPROVE

対象: ブランチ fix-oom-large-image-payloads(コミット 3f812d16 の1件、origin/main の先頭を祖先に持つ)。

ブロッカー

なし

提案(非ブロッキング)

  • コミットメッセージと KDoc の「3回コピー」「any large image ... paid this each time」は事実より強い。PR 本文に転記する前に直したい。
    • 旧コードは1回の呼び出しで最大2回しかコピーしない。画像の data URL では removePrefix、substring(comma+1) の2回で、そこで imageMime が return する。非画像・非 base64 では removePrefix と拡張子フォールバックの lowercase() の2回。3回すべてが同じ呼び出しで起きる経路はない。
    • 宣言された MIME が image/* の画像パートは、toChatPart が早期 return するのでコピーは0回(旧コードも)。OOM が出た経路(lowercase() に到達)は、parseDataImageUri が null を返した場合だけなので、「画像パート」ではなく、宣言 MIME が画像でない file パートの data URL(例: OpenCodeV2ApiClient.kt:1303-1304 が組み立てる data:application/octet-stream;base64,... や PDF)である。
    • 推奨文面: 「宣言 MIME が画像でない、またはヘッダ不正な大きな data URL で、1呼び出しあたり最大2回のペイロードコピーが走り、拡張子フォールバックの lowercase() が 76MB の確保に失敗した。コピーは不要なのでヘッダ1024文字だけを読む」。
  • ChatImageViewer.kt:196-197(loadChatImageBytes)— parseDataImageUri で base64 を substring してから MAX_IMAGE_BASE64_CHARS を検査している。過大な data URL は、1回の 76MB コピーを作ってから拒否される。旧コードは2回で、今回は1回に減っただけで、OOM の発生点が拒否前のコピーに残る。ヘッダ長を足した上限(source.url.length が MAX_IMAGE_BASE64_CHARS に1024を足した値以下)を先に確認すれば、拒否は割り当てなしでできる。
  • CodexItemParser.kt:167(userInputImagePart)— url.substringAfter("data:", "").substringBefore(';') は、data URL 全体を1回コピーしてから MIME を取り出す。Codex の履歴を読むたびに通る、同種のコピーが残る箇所。dataImageMime(url) ?: "image/png" で置き換えられる。同種の箇所は AttachmentImporter.kt:62 と AntigravityRuntime.kt:586 の substringAfter(";base64,") にもあるが、こちらは送信側で本件のロード経路ではない。
  • DataImageMimeTest.kt — 1024文字の上限を固定するテストがない。
    • 上限の挙動は旧コードからの仕様変更なのに、現在のテストはどれもカンマが先頭付近(21〜22文字目)にあり、indexOf(',') を無制限に走査する実装に戻しても通る。
    • 次の2点を足したい。①"data:image/png;base64" + "A".repeat(2_000_000)(カンマなし)を WatchedChars で渡し、null が返り highestIndexRead が1024付近で止まること。②ヘッダが1024文字を超える URL(例: data:image/png;name= に長い名前、;base64,AAAA)が null になる境界値。
  • effectiveImageMime(ChatImageViewer.kt:267-270、private)の挙動変更(data URL かつ非画像なら image/png)にテストがない。safeImageFilename は public だが未テストなので、ChatImageSource("data:text/plain,notes.png", "image/*") などで、拡張子が .png になり、ペイロード中の . で誤判定されないことを確認するテストを足せる。
  • ChatMessageComponents.kt:619-632(decodeImageFromUrlOrPath、decodeDataImage)は、本調査どおり未使用のデッドコード。別PRで削除したい。decodeDataImage は残る限り、base64 全体をデコードするパスとして将来の誤用を招く。

チェック済み項目

  • (a) 旧 parseDataImageUri との同値性: 実コードで比較した。
    • 旧実装は payload = url.removePrefix("data:")、comma = payload.indexOf(',')、meta = payload.substring(0, comma) と進み、endsWith(";base64") と startsWith("image/") を判定し、payload.substring(comma+1) を返す。
    • 新実装の dataImageMime は、インデックス5("data:" の直後)から min(length, 1024) 未満を走査し、最初のカンマで subSequence(5, comma).toString() を取り、同じ判定をする。"data:" にカンマは含まれないので、新実装が見つけたカンマは、parseDataImageUri 側の url.indexOf(',') が返す位置と必ず同じ(dataImageMime が null でない時点で先頭1024文字内にカンマがあり、それが URL 全体の最初のカンマ)。したがって substring(indexOf(',') + 1) はペイロードと完全一致する。空ペイロード、カンマ直後、meta が空、MIME パラメータ付きの扱いも同じ。
    • 差が出るのは「最初のカンマが先頭1024文字より後にある data URL」だけ。旧コードは受理し、新コードは null を返す。アプリ内の生成元(AttachmentImporter、CodexItemParser:357、OpenCodeV2ApiClient:1304)は data:<mime>;base64, の短いヘッダしか作らず、MIME もサーバ由来の短い文字列。実際に1024文字を超えるのは、;name= などで長いパラメータが付く外部由来の URL に限られる。影響は、toChatPart でそのパートが表示から落ちること(旧実装では表示された)と、loadChatImageBytes が「Invalid image data URL」で失敗してプレビューに落ちること。クラッシュにはならず、許容できる。KDoc に上限が書かれているのも妥当。
  • (b) imageMime / effectiveImageMime の挙動変更:
    • 呼び出し箇所を全ソースセット(main、test、androidTest、fdroid、github)で grep した。parseDataImageUri は loadChatImageBytes と未使用の decodeDataImage のみ、imageMime は toChatPart、effectiveImageMime は safeImageFilename と saveChatImageToPictures のみで、取りこぼしはない。
    • 変更後の差分は、data URL が非画像(または非 base64)のときだけ。旧実装は拡張子フォールバックに落ちて誤判定しうる(ペイロード中の .png を拾うなど)が、base64 本文に . は含まれないので、通常は null/image/png に落ちていた。新実装は imageMime で null、effectiveImageMime で image/png を返す。画像として扱われていたケースは、どちらも parseDataImageUri が成功した(画像 MIME かつ base64 かつヘッダが短い)場合に限られ、そこは変わらない。URL エンコードされた SVG(data:image/svg+xml,...)は、旧実装でも parseDataImageUri が null で、拡張子フォールバックはほぼ null/image/png に落ちており、実質的に退行はない。
    • 宣言 MIME が画像の場合の早期 return、toChatPart の isNotBlank()(先頭の非空白で止まる)、ChatImageSource の使い方に変化はない。通常の URL(https://...png、/workspace/photo.jpg)の拡張子判定は従来どおり。
  • (c) decodeImageFromUrlOrPath / decodeDataImage は未使用: リポジトリ全体を grep した(build、.git、.gradle 以外)。ヒットは定義と decodeImageFromUrlOrPath から decodeDataImage への1箇所だけで、app/src/test、androidTest、fdroid、github の各ソースセット、Manifest、proguard-rules.pro にも参照はない。単一モジュール(app)のため internal の外部利用もなく、リフレクション参照もない。本当に未使用で、触らない判断は妥当。
  • (d) テストの検証力:
    • WatchedChars は、CharSequence.startsWith(get を5回)、indexOf(indexOfAny 経由の get)、subSequence、toString の経路をすべて拾う。ヘッダだけを読むこと(highestIndexRead < 1024)と、1024文字超の subSequence や toString() が呼ばれないこと(largeCopies == 0)は、実際に検証できている。
    • imageMime のテストは結果ベースで、$payload.png を使った「データ URL から拡張子を拾わない」の検証と、通常 URL と宣言 MIME 優先の検証が揃っている(旧実装は dataImageMime が無くコンパイルエラーになる)。parseDataImageUri の「ペイロード全体が一致する」検証も、既存の ChatImagePartTest と合わせて同値性を押さえている。
    • 弱い点は上記の提案のとおり、1024文字の上限そのものが未固定であること。
  • (e) 「OOM が減る」根拠:
    • 確実に言えるのは、クラッシュ地点(imageMime の lowercase())の割り当てが無くなり、toChatPart 経路で data URL に対するコピーが0になること。トレース上、この3件・2ユーザーの再発は止まる見込みが高い。
    • 過大にならないよう注意したい点は4つ。①android:largeHeap="true" が既に有効(AndroidManifest.xml:58)なのに 76MB の確保が失敗しているので、ヒープはすでにかなり埋まっていた。1回の一時コピーを減らしても、別の割り当てで OOM が移るだけの可能性が残る。②同じペイロードは、V2 クライアントで JSON 本文、file.textOf("data")、"data:$mime;base64,$data" のテンプレート結果(OpenCodeV2ApiClient.kt:1303-1304)と、複数の文字列で同時に載る。ロード全体のピークを決めるのはこちらで、本PRはその一部にしか効かない。③toUiMessage(ChatViewModel.kt:2596 付近)の PromptAttachment(url = part.url) や reloadedImageUrls(:175)は参照保持とハッシュ計算だけでコピーはないが、文字列は会話の存続中ずっと保持される。④ビューア経路(loadChatImageBytes)は1回のコピーが残る(上記の提案)。
    • 計画中の PR 本文「実機未検証、OOM の根本原因(保持メモリ)は未特定で、ピーク時の一時コピーを減らす変更」は、この制約に整合していて妥当。ここに「Crashlytics の OOM メッセージ(free / target footprint)で、失敗時の余裕が 76MB 未満だったか確認する」を加えると、効果の見積りが具体的になる。
  • ハードルール: ユーザー向け文言の追加なし(i18n 影響なし)、シークレット混入なし、コミットは1件、ブランチは origin/main 基点、テストあり。spotless・detekt・単体テスト(1624件)のグリーンは依頼文の報告に依拠しており、再実行はしていない(読み取り専用のため)。assembleGithubDebug と lintGithubDebug は実行中とのことなので、マージ前に緑であることを確認してほしい。

🤖 Generated with Claude Code

…ing it

Crashlytics (v1.2.27) has an OOM in ChatViewModel.imageMime: a 76 MB
allocation failed inside String.toLowerCase on a file part's data URL. The
heap was already nearly full (the 76 MB string is itself resident), so the
extra copy is what tipped it over.

Finding the MIME type of a `data:` URL copied the payload up to twice per call:
- parseDataImageUri removed the "data:" prefix (a full copy) and then took
  the payload after the comma (a second copy), only for imageMime to keep
  the mime.
- When it returned null - a part whose declared type is not an image, such as
  an application/octet-stream or PDF data URL - imageMime fell through to its
  file-extension fallback, whose lowercase() copied the payload again, after
  looking for ".png" inside base64 text. That is the allocation that failed.

toChatPart runs for every part on every transcript load. Parts that declare an
image type return before reaching this, so the failing path is non-image data
URLs.

dataImageMime reads only the first 1024 characters, so the type comes from the
header and nothing larger than the header is copied. imageMime and the image
viewer's fallback no longer sniff an extension out of a data URL, and
parseDataImageUri now makes the one copy its callers need (the base64 to
decode). Ordinary URLs behave as before.

This removes the allocation that failed; it does not explain why the heap was
nearly full. loadChatImageBytes still copies once before rejecting an
oversized payload.

DataImageMimeTest watches how far into the text the header check reads and
whether it copies a large span; it did not compile before the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit a1cd070 into main Oct 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant