Skip to content

feat(chat): paste clipboard images into the composer - #403

Merged
yuga-hashimoto merged 2 commits into
mainfrom
claude/review-new-issues-9d0867
Oct 25, 2026
Merged

yuga-hashimoto merged 2 commits into
mainfrom
claude/review-new-issues-9d0867

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Closes #401

Summary

A copied screenshot or image gave the message field nothing to paste, so it had to be saved and re-attached from the gallery. The composer now attaches it directly.

  • Long-press / cursor-handle menu: when the clipboard holds an image and no text, the field's selection toolbar offers Paste, which attaches the image (ImagePasteTextToolbar wraps the stock TextToolbar; every other action and the stock text Paste pass through untouched).
  • Hardware keyboard: Ctrl+V / Shift+Insert attach it too (first KeyDown only, so holding the keys does not attach the image repeatedly).
  • Same attach path as the gallery: the image goes through decodeImageUri → onImageAttachment, so the usual thumbnail and chip show before sending.
  • Text wins: a clipboard that carries text keeps the normal text paste, in both the toolbar and the shortcut.
  • Privacy: showing the menu only reads the clip's description (no "pasted from your clipboard" notice on Android 12+); the content is read only when the user actually pastes. Only content:// URIs are accepted, and their types are resolved off the main thread.

Behavior changes beyond paste

The gallery picker now shares decodeImageUri with paste:

  • It reads through the existing 25 MB readLimited cap instead of an unbounded readBytes(). An original larger than 25 MB is now rejected instead of decoded (only a ≤1024 px thumbnail is sent anyway; the cap protects memory).
  • A pasted or gallery image that cannot be attached now shows the existing attachment_load_failed toast instead of failing silently. No new strings were added.

Verification

  • ./gradlew :app:testGithubDebugUnitTest — 1427 tests, 0 failures (8 skipped, as before). New: ClipboardImagePasteTest (9) and ReadLimitedTest (3).
  • ./gradlew :app:lintGithubDebug spotlessCheck — pass. (The root detekt task reports NO-SOURCE in this repo, so it checks nothing; CI runs the same.)
  • Emulator (API 36, Gboard), final build: copy an image in Chrome → long-press the message field → Paste attaches it; Ctrl+V attaches exactly one; with text on the clipboard Ctrl+V pastes text and attaches nothing.

Known limits / follow-ups

  • Images inserted straight from the keyboard (Gboard clipboard suggestions, stickers) need contentReceiver, which only the state-based BasicTextField supports; the composer still uses the value-based one. Not covered here.
  • A clipboard built with ClipData.newRawUri (MIME text/uri-list) is not recognised as an image.
  • When bumping the Compose BOM, re-check long-press → Paste by hand: ImagePasteTextToolbar overrides the 5-argument showMenu, and a future overload would slip past by delegate and the unit tests.
  • Reviewer suggestions left for later: one toast for a multi-image gallery failure instead of one per image; extract the content:///MIME filter into a pure function so it can be unit-tested; translate attachment_load_failed (still English in ar/es/fr/pt-rBR/ru/zh-rCN).

Pre-PR review

Reviewed at HEAD 5f61895b (2 commits on origin/main f6398e6b). The named repo-reviewer subagent type is not available in this environment, so a general-purpose subagent was given .opencode/agents/repo-reviewer.md and told to follow it read-only. Its report, verbatim:

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • ChatHomeScreen.kt:341-345 / 350 — ギャラリー経路の挙動変更点が2つあります。マージ可否には影響しませんが、PR本文に一言あると親切です。
    • 失敗が1枚ごとに Toast になります。複数枚を選んで全て失敗すると Toast が枚数分たまります。失敗を数えて最後に1回だけ出すとより自然です。
    • 旧実装は無制限の readBytes() でしたが、今回から readLimited(25MB上限)になりました。25MB超の原本(大きなRAW/HEICなど)は、以前は添付できたのに今回から失敗 Toast になります。実際に送られるのは1024px以下にダウンサンプルした Bitmap(AndCodeApp.kt:1181 の AttachmentImporter.import(bitmap))なので、上限の意図(メモリ保護)は妥当です。
  • ClipboardImagePaste.kt:88-93 — セキュリティ上の要である「content:// のみ許可」のフィルタに単体テストがありません。JVMテストでは Uri がスタブ(isReturnDefaultValues = true、Robolectric なし)のためテストしにくいのが原因です。isClipImageUri(scheme: String?, mimeType: String?) のような純粋関数に切り出せば、file:// が弾かれることをテストで固定できます。
  • ReadLimitedTest.kt:46 — LIMIT が MAX_IMAGE_BYTES の複製です。本体側を internal にしてテストから参照すれば、定数を変えたときの乖離を防げます。現状のコメントで十分とする判断もありです。
  • attachment_load_failed は ja 以外の6ロケール(ar, es, fr, pt-rBR, ru, zh-rCN)が英語文のままです。これは origin/main から続く状態で、今回の差分では変えていません。ただし今回の貼り付け失敗 Toast で目に触れる機会が増えるため、別PRで翻訳するとよいです。i18n-check はキーの有無しか見ないので CI には影響しません。
  • ClipboardImagePaste.kt:49-65 — ImagePasteTextToolbar は Compose 1.7.6(BOM 2024.12.01)の5引数 showMenu だけを上書きしています。将来 Compose で showMenu のオーバーロードが増えると、by delegate がその呼び出しを素通しにして画像Pasteが出なくなる恐れがあります。コンパイルは通り、テストも5引数を直接呼ぶので検知できません。BOM を上げるときは、実機で長押し→Paste を目視確認してください。
  • ChatHomeScreen.kt:1517-1524 — Ctrl+V の KeyDown リピート(repeatCount > 0)は false を返して標準の貼り付け処理に落ちます。画像のみのクリップボードでは、標準の貼り付けはテキストを読むだけなので実害はない見込みです(Compose のソースはローカルで確認できませんでした)。念のため、ショートカットかつ画像のみのクリップボードならリピートも consume する方が堅くなります。
  • 既存の事項: onImageAttachment(AndCodeApp.kt:1181)は AttachmentImporter.import(bitmap) が失敗しても onSuccess のみで、Toast が出ません。ギャラリーやカメラと共通の既存挙動なので今回の範囲外です。

チェック済み項目

  • 差分の確認: git fetch origin main の後、origin/main は f6398e6、merge-base(origin/main, HEAD) は f6398e6 と一致しました。git diff origin/main...HEAD の5ファイル(387行追加、54行削除)を全て読みました。前回の APPROVE には依存していません。
  • 観点1(5f61895b の新規不具合)
    • ClipData.imageUris は withContext(IO) で getType を呼び、戻りはメインスレッドです。
    • onPasteImage 内の coroutineScope.launch は rememberCoroutineScope(メインのコンポジション文脈)上で動きます。showAttachFailure() は imageUris と decodeImageUri(どちらも withContext(IO) から復帰後)の後に呼ばれるため、Toast は常にメインスレッドです(ChatHomeScreen.kt:741-747, 341-345)。
    • attachImageUris は suspend ラムダで、呼び出し元は2か所(ギャラリーのコールバックとペースト)です。どちらも coroutineScope.launch 内から呼ばれます。
    • rememberLauncherForActivityResult は内部で rememberUpdatedState を使うので、毎回再生成されるラムダを渡しても最新のものが使われます。
    • ギャラリーでキャンセルされて空リストが返ったときは、uris.forEach が何もしないので Toast は出ません。ペースト側は uris.isEmpty() のときだけ Toast を出し、クリップ未取得(null)の場合も失敗扱いです。
    • ChatHomeScreen.kt から Dispatchers と withContext の import を消していますが、同ファイルに使用箇所が残っていないことを grep で確認しました。
    • decodeImageUri の catch (e: Exception) は、try 内に suspend ポイントがないので CancellationException を握りつぶしません。runCatching も同様です。
  • 観点2(ImagePasteTextToolbar と Compose 状態管理)
    • LocalTextToolbar.current は CompositionLocalProvider の外で読んでおり、自己参照になりません。
    • remember(baseTextToolbar, context) でラッパーを保持します。
    • currentOnPasteImage は rememberUpdatedState 経由なので古いラムダを掴みません。
    • status と hide() は by delegate で元のツールバーと状態を共有します。
    • 標準が Paste を出す場合(onPasteRequested != null)はそのまま通し、他のアクションも素通しです。
    • ChatComposer の呼び出し元は1か所(ChatHomeScreen.kt:639)で、新しい必須引数 onPasteImage も渡されています。
    • Ctrl+V の onPreviewKeyEvent では、条件の評価順が KeyDown → repeatCount → ショートカット → クリップボード参照です。そのため、クリップボードへのアクセスはショートカット押下時だけです。
  • 観点3(セキュリティ/プライバシー)
    • クリップボードの中身(primaryClip)は、ユーザーが Paste を押した時と Ctrl+V の時にしか読みません。表示判定は primaryClipDescription だけで、Android 12+ の「クリップボードを読み取った」通知を誘発しません。
    • primaryClip はコルーチン起動前に同期で取得されるので、クリップに付いた URI 権限は押下時点のものです。
    • content:// 以外(file:// など)は除外されます。クリップボードの所有者が URI を決められるので、file:// を許すと自アプリの private ファイルを読ませる confused-deputy になり得ます。この除外は妥当です。
    • AndroidManifest に自アプリの公開 FileProvider はありません(provider は非公開の androidx.startup.InitializationProvider のみ)。
    • getType と openInputStream の例外は null/false に落ち、クラッシュしません。
    • 読み込みは readLimited(25MB)で上限があります。
  • 観点4(i18n)
    • 差分に res/ の変更はなく、新しい文字列キーは増えていません。
    • 既存の attachment_load_failed が values と ar, es, fr, ja, pt-rBR, ru, zh-rCN の全8ファイルに1件ずつ存在することを grep で確認しました。
    • 新規コードにハードコードの UI 文字列はありません(Toast は R.string、ログは英語)。
  • 観点5(テスト)
    • ClipboardImagePasteTest の9件は、真理値表(text 優先、画像のみ、空)、ショートカット(肯定と否定)、ツールバー契約(image-only のとき Paste 供給と hide()、標準 Paste の素通し、画像なしで Paste なし、他アクションと status の素通し)を網羅しています。RecordingToolbar は Compose 1.7.6 の TextToolbar と一致します。
    • ReadLimitedTest の3件は、ちょうど上限、1バイト超過で IllegalArgumentException、空ストリームです。zeros() は実バッファを確保しないので軽く、readLimited の require 境界(total <= MAX)と一致します。
    • 追加した単体テストはいずれも Robolectric 不要の純粋JVMで書かれています。
  • ハードルール
    • ルール6: このworktreeは scripts/new-worktree.sh ではなく実行環境が作成したものです。ただし git worktree list で、メインツリー(main, f6398e6)とは別のリンクworktree上のブランチ claude/review-new-issues-9d0867 であることを確認しました。merge-base が origin/main の先端と一致するので、スクリプトの git worktree add -b ... origin/main と実質同じ状態です。ブロッカーにはしません。
    • ルール3: 挙動変更に対応する単体テストが追加されています。
    • ルール2: spotless と lint はご報告どおりとして扱いました。私は読み取り専用のため Gradle を実行していません。detekt はご報告に含まれていませんが、config/detekt/baseline.xml が空にもかかわらず既存の ChatHomeScreen は LongMethod の閾値80行を大幅に超えており、ルートの detekt {} に source 指定もないため、app/ の Kotlin をほぼ検査していないと推測します(既存の構成の問題で、今回の差分とは無関係)。新規コードに detekt の既定ルール違反(ReturnCount、LongParameterList など)が出る見込みは低いです。
    • ルール4: シークレットの追加はありません。

🤖 Generated with Claude Code

yuga-hashimoto and others added 2 commits October 25, 2026 07:45
A copied screenshot or image gave the message field nothing to paste, so
it had to be saved and re-attached from the gallery. The field now offers
Paste in its selection toolbar when the clipboard holds an image and no
text, and Ctrl+V / Shift+Insert attach it from a hardware keyboard. The
image goes through the same decode-and-attach path as the gallery picker,
so it shows the usual thumbnail before sending. A clipboard that carries
text keeps the normal text paste.

The gallery picker now shares decodeImageUri with the paste path, which
reads through the existing 25 MB readLimited cap instead of readBytes().

Closes #401

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Resolve the clip's URI types off the main thread: getType() calls into
  the app that copied the image, which can be slow to answer.
- Accept only content:// URIs from the clipboard, so a file:// URI chosen
  by the clip's owner cannot point at this app's own files.
- Attach on the first KeyDown only, so holding Ctrl+V does not attach the
  same image repeatedly.
- Tell the user when a pasted or gallery image cannot be attached, using
  the existing attachment_load_failed string.
- Move decodeImageUri next to decodeSampledBitmap/readLimited and cover
  the 25 MB readLimited boundary with unit tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit 7f79815 into main Oct 25, 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.

Feature Request: Support Pasting Images Directly from Clipboard

1 participant