Repository navigation
feat(chat): paste clipboard images into the composer - #403
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ImagePasteTextToolbarwraps the stockTextToolbar; every other action and the stock text Paste pass through untouched).decodeImageUri→onImageAttachment, so the usual thumbnail and chip show before sending.content://URIs are accepted, and their types are resolved off the main thread.Behavior changes beyond paste
The gallery picker now shares
decodeImageUriwith paste:readLimitedcap instead of an unboundedreadBytes(). An original larger than 25 MB is now rejected instead of decoded (only a ≤1024 px thumbnail is sent anyway; the cap protects memory).attachment_load_failedtoast instead of failing silently. No new strings were added.Verification
./gradlew :app:testGithubDebugUnitTest— 1427 tests, 0 failures (8 skipped, as before). New:ClipboardImagePasteTest(9) andReadLimitedTest(3)../gradlew :app:lintGithubDebug spotlessCheck— pass. (The rootdetekttask reportsNO-SOURCEin this repo, so it checks nothing; CI runs the same.)Known limits / follow-ups
contentReceiver, which only the state-basedBasicTextFieldsupports; the composer still uses the value-based one. Not covered here.ClipData.newRawUri(MIMEtext/uri-list) is not recognised as an image.ImagePasteTextToolbaroverrides the 5-argumentshowMenu, and a future overload would slip pastby delegateand the unit tests.content:///MIME filter into a pure function so it can be unit-tested; translateattachment_load_failed(still English in ar/es/fr/pt-rBR/ru/zh-rCN).Pre-PR review
Reviewed at HEAD
5f61895b(2 commits onorigin/mainf6398e6b). The namedrepo-reviewersubagent type is not available in this environment, so a general-purpose subagent was given.opencode/agents/repo-reviewer.mdand told to follow it read-only. Its report, verbatim:判定: APPROVE
ブロッカー
なし
提案(非ブロッキング)
readBytes()でしたが、今回からreadLimited(25MB上限)になりました。25MB超の原本(大きなRAW/HEICなど)は、以前は添付できたのに今回から失敗 Toast になります。実際に送られるのは1024px以下にダウンサンプルした Bitmap(AndCodeApp.kt:1181 のAttachmentImporter.import(bitmap))なので、上限の意図(メモリ保護)は妥当です。Uriがスタブ(isReturnDefaultValues = true、Robolectric なし)のためテストしにくいのが原因です。isClipImageUri(scheme: String?, mimeType: String?)のような純粋関数に切り出せば、file://が弾かれることをテストで固定できます。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 には影響しません。ImagePasteTextToolbarは Compose 1.7.6(BOM 2024.12.01)の5引数showMenuだけを上書きしています。将来 Compose でshowMenuのオーバーロードが増えると、by delegateがその呼び出しを素通しにして画像Pasteが出なくなる恐れがあります。コンパイルは通り、テストも5引数を直接呼ぶので検知できません。BOM を上げるときは、実機で長押し→Paste を目視確認してください。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 には依存していません。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も同様です。ImagePasteTextToolbarと Compose 状態管理)LocalTextToolbar.currentはCompositionLocalProviderの外で読んでおり、自己参照になりません。remember(baseTextToolbar, context)でラッパーを保持します。currentOnPasteImageはrememberUpdatedState経由なので古いラムダを掴みません。statusとhide()はby delegateで元のツールバーと状態を共有します。onPasteRequested != null)はそのまま通し、他のアクションも素通しです。ChatComposerの呼び出し元は1か所(ChatHomeScreen.kt:639)で、新しい必須引数onPasteImageも渡されています。onPreviewKeyEventでは、条件の評価順が KeyDown → repeatCount → ショートカット → クリップボード参照です。そのため、クリップボードへのアクセスはショートカット押下時だけです。primaryClip)は、ユーザーが Paste を押した時と Ctrl+V の時にしか読みません。表示判定はprimaryClipDescriptionだけで、Android 12+ の「クリップボードを読み取った」通知を誘発しません。primaryClipはコルーチン起動前に同期で取得されるので、クリップに付いた URI 権限は押下時点のものです。content://以外(file://など)は除外されます。クリップボードの所有者が URI を決められるので、file://を許すと自アプリの private ファイルを読ませる confused-deputy になり得ます。この除外は妥当です。androidx.startup.InitializationProviderのみ)。getTypeとopenInputStreamの例外はnull/falseに落ち、クラッシュしません。readLimited(25MB)で上限があります。res/の変更はなく、新しい文字列キーは増えていません。attachment_load_failedがvaluesと ar, es, fr, ja, pt-rBR, ru, zh-rCN の全8ファイルに1件ずつ存在することを grep で確認しました。R.string、ログは英語)。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)と一致します。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と実質同じ状態です。ブロッカーにはしません。config/detekt/baseline.xmlが空にもかかわらず既存のChatHomeScreenは LongMethod の閾値80行を大幅に超えており、ルートのdetekt {}にsource指定もないため、app/ の Kotlin をほぼ検査していないと推測します(既存の構成の問題で、今回の差分とは無関係)。新規コードに detekt の既定ルール違反(ReturnCount、LongParameterList など)が出る見込みは低いです。🤖 Generated with Claude Code