diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 1703a76702024..15f907a1fc321 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -9,6 +9,10 @@ import org.json.JSONObject import org.json.JSONArray internal object T3ComposerClipboard { + // Clipboard bridges can echo our text back without its HTML context metadata. + private var copiedContext: Pair? = null + + @Synchronized fun write(context: Context, text: String, fragment: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val payload = try { @@ -18,6 +22,7 @@ internal object T3ComposerClipboard { } val records = payload?.optJSONArray("records") if (payload == null || records == null) { + copiedContext = null clipboard.setPrimaryClip(ClipData.newPlainText("T3 Code", text)) return } @@ -31,8 +36,10 @@ internal object T3ComposerClipboard { } ) payload.put("records", JSONArray(selected)) - val encoded = java.net.URLEncoder.encode(payload.toString(), "UTF-8").replace("+", "%20") + val serialized = payload.toString() + val encoded = java.net.URLEncoder.encode(serialized, "UTF-8").replace("+", "%20") val escaped = text.replace("&", "&").replace("<", "<").replace(">", ">") + copiedContext = if (selected.isEmpty()) null else text to serialized clipboard.setPrimaryClip( if (selected.isEmpty()) { ClipData.newPlainText( @@ -49,14 +56,21 @@ internal object T3ComposerClipboard { ) } + @Synchronized fun read(context: Context): Map { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = clipboard.primaryClip val item = if (clip != null && clip.itemCount > 0) clip.getItemAt(0) else null + val text = item?.text?.toString() ?: "" + val html = item?.htmlText ?: "" + val copied = copiedContext?.takeIf { + clip?.itemCount == 1 && item?.uri == null && it.first == text + } + if (copied == null) copiedContext = null return mapOf( - "text" to (item?.text?.toString() ?: ""), - "html" to (item?.htmlText ?: ""), - "fragment" to "" + "text" to text, + "html" to html, + "fragment" to if (!html.contains("data-t3-context-fragment=")) copied?.second.orEmpty() else "" ) } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ecc37df17c993..2e5cf0cf3f2fc 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -697,7 +697,10 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { private fun pasteContextOrImages(): Boolean { val payload = T3ComposerClipboard.read(context) - if (payload["html"]?.contains("data-t3-context-fragment=") == true) { + if ( + !payload["fragment"].isNullOrEmpty() || + payload["html"]?.contains("data-t3-context-fragment=") == true + ) { pasteContextListener?.invoke(payload) return true } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index ca15fb2b1ba19..682865b019e8f 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -6,6 +6,7 @@ import android.content.ClipboardManager import android.content.Context import android.net.Uri import android.view.KeyEvent +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -38,6 +39,31 @@ class ComposerPasteTest { ) ) + @Test + @Config(sdk = [33, 36]) + fun sentImageCopySurvivesAClipboardBridgeDroppingHtml() { + assertSentImageContextPaste() + } + + @Test + fun clipboardChangeObservedByPasteDiscardsTheCopiedContext() { + assertSentImageContextPaste() + val copiedText = clipboard.primaryClip!!.getItemAt(0).text.toString() + clipboard.setPrimaryClip(ClipData.newPlainText("other app", "Different text")) + assertEquals("", T3ComposerClipboard.read(context)["fragment"]) + clipboard.setPrimaryClip(ClipData.newPlainText("other app", copiedText)) + assertEquals("", T3ComposerClipboard.read(context)["fragment"]) + } + + @Test + fun explicitPlainTextPasteDoesNotRecoverContext() { + assertSentImageContextPaste() + val copiedText = clipboard.primaryClip!!.getItemAt(0).text.toString() + editor.pasteContextListener = { error("Paste as Text must stay plain") } + assertTrue(pasteAsText()) + assertEquals(copiedText, editor.text.toString()) + } + @Test fun shortcutKeepsLargeTextInlineAndUndoable() { val pasted = "x".repeat(32 * 1024) @@ -169,4 +195,33 @@ class ComposerPasteTest { assertEquals("unchanged", editor.text.toString()) assertNull(intercepted) } + + private fun assertSentImageContextPaste() { + val id = "file_sent-image" + val copiedText = "Review this image\n ![android-smoke.png](t3-context://v1/image/$id)" + val fragment = """ + { + "version": 1, + "source": {"environmentId": "source", "messageId": "sent-message"}, + "records": [{ + "version": 1, "contextId": "$id", "label": "android-smoke.png", "kind": "image", + "attachmentId": "stored-image", "name": "android-smoke.png", + "mimeType": "image/png", "sizeBytes": 53249 + }] + } + """.trimIndent() + T3ComposerClipboard.write(context, copiedText, fragment) + clipboard.setPrimaryClip(ClipData.newPlainText("clipboard bridge", copiedText)) + editor.setText("") + editor.setSelection(0) + var pasted: Map? = null + editor.pasteContextListener = { pasted = it } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(copiedText, pasted?.get("text")) + val recovered = JSONObject(requireNotNull(pasted?.get("fragment"))) + assertEquals("source", recovered.getJSONObject("source").getString("environmentId")) + assertEquals(id, recovered.getJSONArray("records").getJSONObject(0).getString("contextId")) + assertEquals("", editor.text.toString()) + } }