Skip to content
Merged
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
12 changes: 12 additions & 0 deletions apps/mobile/modules/t3-composer-editor/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ android {
namespace 'expo.modules.t3composereditor'
compileSdk rootProject.ext.compileSdkVersion

testOptions {
unitTests.includeAndroidResources = true
}

defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
Expand All @@ -17,4 +21,12 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation project(':t3tools-mobile-markdown-text')
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}

tasks.withType(Test).configureEach {
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package expo.modules.t3composereditor

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.views.ViewDefinitionBuilder
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
Expand Down Expand Up @@ -61,9 +62,22 @@ internal object T3ComposerClipboard {
}
}

private fun ViewDefinitionBuilder<T3ComposerEditorView>.registerPasteConfiguration() {
Prop("clipboardFragment") { view: T3ComposerEditorView, fragment: String ->
view.setClipboardFragment(fragment)
}
Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int ->
view.setTextPasteThresholdBytes(threshold)
}
Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int ->
view.setMaxInputChars(maxInputChars)
}
}

class T3ComposerEditorModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3ComposerEditor")
Constants("textPasteAttachmentRevision" to 1)

AsyncFunction("writeContextClipboard") { text: String, fragment: String ->
T3ComposerClipboard.write(requireNotNull(appContext.reactContext), text, fragment)
Expand All @@ -76,9 +90,7 @@ class T3ComposerEditorModule : Module() {
Prop("themeJson") { view: T3ComposerEditorView, themeJson: String ->
view.setThemeJson(themeJson)
}
Prop("clipboardFragment") { view: T3ComposerEditorView, fragment: String ->
view.setClipboardFragment(fragment)
}
registerPasteConfiguration()
Prop("placeholder") { view: T3ComposerEditorView, placeholder: String ->
view.setPlaceholder(placeholder)
}
Expand Down Expand Up @@ -116,7 +128,6 @@ class T3ComposerEditorModule : Module() {
Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean ->
view.setSpellCheck(spellCheck)
}

Events(
"onComposerChange",
"onComposerSelectionChange",
Expand All @@ -125,6 +136,7 @@ class T3ComposerEditorModule : Module() {
"onComposerPasteImages",
"onComposerContextPress",
"onComposerPasteContext",
"onComposerPasteText",
"onComposerContentSizeChange",
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package expo.modules.t3composereditor

import android.content.Context
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.Canvas
import android.graphics.Paint
Expand Down Expand Up @@ -52,6 +53,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private val onComposerPasteImages by EventDispatcher()
private val onComposerContextPress by EventDispatcher()
private val onComposerPasteContext by EventDispatcher()
private val onComposerPasteText by EventDispatcher()
private val onComposerContentSizeChange by EventDispatcher()
private var applyingNativeValue = false
private var desiredLineHeightPx = 0
Expand Down Expand Up @@ -90,7 +92,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
editor.pasteImagesListener = { uris ->
onComposerPasteImages(mapOf("uris" to uris))
}
editor.pasteContextListener = { payload -> onComposerPasteContext(payload) }
editor.pasteContextListener = { payload ->
nativeEventCount += 1
onComposerPasteContext(
payload + mapOf(
"value" to editor.text.toString(),
"eventCount" to nativeEventCount,
"selection" to currentSelectionPayload(),
)
)
}
val contextGestures =
GestureDetector(
context,
Expand All @@ -113,6 +124,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
contextGestures.onTouchEvent(event)
false
}
editor.pasteTextListener = { text, start, end ->
nativeEventCount += 1
onComposerPasteText(
mapOf(
"value" to editor.text.toString(),
"eventCount" to nativeEventCount,
"text" to text,
"selection" to currentSelectionPayload(start, end),
),
)
}
editor.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
onComposerFocus(emptyMap<String, Any>())
Expand Down Expand Up @@ -300,6 +322,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
updateInputFlags()
}

fun setTextPasteThresholdBytes(threshold: Int) {
editor.textPasteThresholdBytes = threshold
}

fun setMaxInputChars(maxInputChars: Int) {
editor.maxInputChars = maxInputChars
}

fun focusEditor() {
editor.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
Expand Down Expand Up @@ -360,10 +390,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
editor.highlightColor = defaultHighlightColor
}

private fun currentSelectionPayload(): Map<String, Int> =
private fun currentSelectionPayload(
start: Int = editor.selectionStart,
end: Int = editor.selectionEnd
): Map<String, Int> =
mapOf(
"start" to editor.selectionStart.coerceAtLeast(0),
"end" to editor.selectionEnd.coerceAtLeast(0),
"start" to minOf(start, end).coerceAtLeast(0),
"end" to maxOf(start, end).coerceAtLeast(0),
)

private fun emitSelectionChange(start: Int, end: Int) {
Expand All @@ -374,7 +407,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
onComposerSelectionChange(
mapOf(
"value" to editor.text.toString(),
"selection" to mapOf("start" to start, "end" to end),
"selection" to currentSelectionPayload(start, end),
"eventCount" to nativeEventCount,
),
)
Expand Down Expand Up @@ -591,11 +624,14 @@ private fun parseTokens(value: String): List<ComposerToken> = try {
emptyList()
}

private class SelectionAwareEditText(context: Context) : EditText(context) {
internal class SelectionAwareEditText(context: Context) : EditText(context) {
var readOnly = false
var selectionListener: ((Int, Int) -> Unit)? = null
var pasteImagesListener: ((List<String>) -> Unit)? = null
var pasteContextListener: ((Map<String, String>) -> Unit)? = null
var pasteTextListener: ((String, Int, Int) -> Unit)? = null
var textPasteThresholdBytes = 0
var maxInputChars = Int.MAX_VALUE
var clipboardFragment = ""

/**
Expand Down Expand Up @@ -680,7 +716,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
}

override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
selectionListener?.invoke(selStart, selEnd)
Expand All @@ -693,6 +728,9 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
}
val handled = when {
id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut)
// Explicit Paste as Text keeps Android's native insertion and undo, even above the
// message limit. The send guard reports an over-limit draft without changing its bytes.
id == android.R.id.pasteAsPlainText -> false
pasting -> pasteContextOrImages()
else -> false
}
Expand All @@ -714,6 +752,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
pasteContextListener?.invoke(payload)
return true
}
return pasteImagesOrInterceptedText()
}

private fun pasteImagesOrInterceptedText(): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = clipboard?.primaryClip
val imageUris = buildList {
Expand All @@ -726,7 +768,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
}
}
}
if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris)
return imageUris.isNotEmpty()
return when {
imageUris.isNotEmpty() -> {
pasteImagesListener?.invoke(imageUris)
true
}
else -> pasteInterceptedText(clip)
}
}

private fun pasteInterceptedText(clip: ClipData?): Boolean {
val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null
if (text.isNullOrEmpty()) return false
val start = minOf(selectionStart, selectionEnd).coerceIn(0, length())
val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars
val shouldFold = (
text.length >= textPasteThresholdBytes ||
text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes
)
val shouldIntercept = exceedsInputLimit || shouldFold
if (shouldIntercept) {
pasteTextListener?.invoke(text, start, end)
}
// Let EditText perform ordinary pastes, retaining its native undo history.
return shouldIntercept
}

// coerceToText opens content: URIs synchronously. Leave URI-backed
// clipboard items to Android's normal paste path so the UI thread never
// reads an arbitrary provider just to measure a text paste.
private fun ClipData.plainText(): String? =
takeIf { itemCount > 0 }
?.getItemAt(0)
?.takeIf { it.uri == null }
?.coerceToText(context)
?.toString()
?.takeIf(String::isNotEmpty)

override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) {
return onTextContextMenuItem(android.R.id.pasteAsPlainText)
}
return super.onKeyShortcut(keyCode, event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package expo.modules.t3composereditor

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], manifest = Config.NONE)
class ComposerPasteTest {
private val context = RuntimeEnvironment.getApplication()
private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
private val editor = SelectionAwareEditText(context).apply {
textPasteThresholdBytes = 32 * 1024
maxInputChars = 120_000
}

private fun pasteAsText(): Boolean = editor.onKeyShortcut(
KeyEvent.KEYCODE_V,
KeyEvent(
0,
0,
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_V,
0,
KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
)
)

@Test
fun shortcutKeepsLargeTextInlineAndUndoable() {
val pasted = "x".repeat(32 * 1024)
clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted))
editor.setText("before old after")
editor.setSelection(10, 7)
editor.pasteTextListener = { _, _, _ -> error("Inline paste must stay native") }

assertTrue(pasteAsText())
assertEquals("before $pasted after", editor.text.toString())
assertTrue(editor.onTextContextMenuItem(android.R.id.undo))
assertEquals("before old after", editor.text.toString())
}

@Test
fun shortcutKeepsOverLimitTextInlineForExplicitSendValidation() {
clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello"))
editor.setText("x".repeat(119_999))
editor.setSelection(10, 7)
var intercepted: Triple<String, Int, Int>? = null
editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) }

assertTrue(pasteAsText())
assertNull(intercepted)
assertEquals(119_999 + "hello".length - 3, editor.length())
assertEquals("hello", editor.text.substring(7, 12))
}

@Test
fun shortcutAllowsReplacementAtTheInputLimit() {
clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello"))
editor.setText("x".repeat(120_000))
editor.setSelection(12, 7)
editor.pasteTextListener = { _, _, _ -> error("Replacement fits the input limit") }

assertTrue(pasteAsText())
assertEquals("hello", editor.text.substring(7, 12))
assertEquals(120_000, editor.length())
}

@Test
fun regularPasteStillFoldsAtTheUtf8Threshold() {
val pasted = "é".repeat(16 * 1024)
clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted))
editor.setText("old")
editor.setSelection(0, 3)
var intercepted: String? = null
editor.pasteTextListener = { text, _, _ -> intercepted = text }

assertTrue(editor.onTextContextMenuItem(android.R.id.paste))
assertEquals(pasted, intercepted)
assertEquals("old", editor.text.toString())
}

@Test
fun shortcutPastesStructuredClipboardAsText() {
clipboard.setPrimaryClip(
ClipData.newHtmlText(
"test",
"plain text",
"<pre data-t3-context-fragment=\"x\">plain text</pre>"
)
)
editor.setSelection(0)
editor.pasteContextListener = { error("Paste as Text must not import structured context") }

assertTrue(pasteAsText())
assertEquals("plain text", editor.text.toString())
}

@Test
fun readOnlyShortcutDoesNotPasteOrEmitAnEvent() {
clipboard.setPrimaryClip(ClipData.newPlainText("test", "x".repeat(120_001)))
editor.setText("unchanged")
editor.setSelection(editor.length())
editor.readOnly = true
var intercepted: String? = null
editor.pasteTextListener = { text, _, _ -> intercepted = text }

assertFalse(pasteAsText())
assertEquals("unchanged", editor.text.toString())
assertNull(intercepted)
}
}
Loading
Loading