From e86f3bffd08cd364b11455f37dc54cf1bf8bef41 Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Wed, 29 Jul 2026 13:08:30 +0100 Subject: [PATCH] fix(mobile): support Android clipboard image paste --- .../t3composereditor/T3ComposerEditorView.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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 e13c0a52189..b9c0d086f98 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 @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface +import android.net.Uri import android.text.Editable import android.text.InputType import android.text.Spanned @@ -14,11 +15,18 @@ import android.text.TextWatcher import android.text.style.ReplacementSpan import android.view.Gravity import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.EditText +import androidx.core.view.ContentInfoCompat +import androidx.core.view.ViewCompat +import androidx.core.view.inputmethod.InputConnectionCompat import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView +import java.io.File +import java.util.UUID import org.json.JSONObject import kotlin.math.max @@ -451,11 +459,28 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null + init { + ViewCompat.setOnReceiveContentListener(this, SUPPORTED_IMAGE_MIME_TYPES) { _, payload -> + val cachedImageUris = cacheReceivedImages(payload) + if (cachedImageUris.isEmpty()) { + payload + } else { + pasteImagesListener?.invoke(cachedImageUris) + null + } + } + } + override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val inputConnection = super.onCreateInputConnection(outAttrs) ?: return null + return InputConnectionCompat.createWrapper(this, inputConnection, outAttrs) + } + override fun onTextContextMenuItem(id: Int): Boolean { if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager @@ -477,4 +502,56 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } return super.onTextContextMenuItem(id) } + + private fun cacheReceivedImages(payload: ContentInfoCompat): List = buildList { + for (index in 0 until payload.clip.itemCount) { + val uri = payload.clip.getItemAt(index).uri ?: continue + val mimeType = context.contentResolver.getType(uri)?.lowercase() ?: continue + val extension = fileExtensionForImageMimeType(mimeType) ?: continue + cacheReceivedImage(uri, extension)?.let(::add) + } + } + + private fun cacheReceivedImage(uri: Uri, extension: String): String? { + val pasteDirectory = File(context.cacheDir, OWNED_PASTED_IMAGE_DIRECTORY) + if (!pasteDirectory.exists() && !pasteDirectory.mkdirs()) { + return null + } + + val destination = File(pasteDirectory, "${UUID.randomUUID()}.$extension") + return try { + val input = context.contentResolver.openInputStream(uri) ?: return null + input.use { source -> + destination.outputStream().use { output -> + source.copyTo(output) + } + } + Uri.fromFile(destination).toString() + } catch (_: Exception) { + destination.delete() + null + } + } + + private companion object { + const val OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste" + + val SUPPORTED_IMAGE_MIME_TYPES = arrayOf( + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + ) + + fun fileExtensionForImageMimeType(mimeType: String): String? = when (mimeType) { + "image/png" -> "png" + "image/jpeg", "image/jpg" -> "jpg" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/heic", "image/heif" -> "heic" + else -> null + } + } }