Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>? = null

@Synchronized
fun write(context: Context, text: String, fragment: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val payload = try {
Expand All @@ -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
}
Expand All @@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
copiedContext = if (selected.isEmpty()) null else text to serialized
clipboard.setPrimaryClip(
if (selected.isEmpty()) {
ClipData.newPlainText(
Expand All @@ -49,14 +56,21 @@ internal object T3ComposerClipboard {
)
}

@Synchronized
fun read(context: Context): Map<String, String> {
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 ""
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String, String>? = 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())
}
}