@openeditor/react-native-prose-editor 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,2581 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Activity
4
- import android.content.Context
5
- import android.graphics.Rect
6
- import android.os.Handler
7
- import android.os.Looper
8
- import android.view.MotionEvent
9
- import android.view.inputmethod.EditorInfo
10
- import android.widget.FrameLayout
11
- import expo.modules.core.ModuleRegistry
12
- import expo.modules.kotlin.AppContext
13
- import expo.modules.kotlin.ModulesProvider
14
- import expo.modules.kotlin.modules.Module
15
- import org.json.JSONArray
16
- import org.json.JSONObject
17
- import org.junit.Assert.assertEquals
18
- import org.junit.Assert.assertFalse
19
- import org.junit.Assert.assertNotNull
20
- import org.junit.Assert.assertNull
21
- import org.junit.Assert.assertSame
22
- import org.junit.Assert.assertTrue
23
- import org.junit.Test
24
- import org.junit.runner.RunWith
25
- import org.robolectric.Robolectric
26
- import org.robolectric.RobolectricTestRunner
27
- import org.robolectric.RuntimeEnvironment
28
- import org.robolectric.Shadows.shadowOf
29
- import org.robolectric.annotation.Config
30
- import java.lang.ref.WeakReference
31
- import java.time.Duration
32
- import java.util.concurrent.CountDownLatch
33
- import java.util.concurrent.atomic.AtomicBoolean
34
- import java.util.concurrent.atomic.AtomicReference
35
-
36
- @RunWith(RobolectricTestRunner::class)
37
- @Config(sdk = [34])
38
- class NativeEditorExpoViewTest {
39
- @Test
40
- fun `standalone toolbar hit testing uses normalized window coordinates only`() {
41
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
42
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
43
- val density = expoContext.context.resources.displayMetrics.density
44
- val visibleWindowFrame = Rect(6, 24, 600, 900)
45
-
46
- view.setToolbarFrameJson("""{"x":20,"y":40,"width":100,"height":32}""")
47
-
48
- assertTrue(
49
- view.isPointInsideStandaloneToolbarForTesting(
50
- rawX = 30f * density + visibleWindowFrame.left,
51
- rawY = 50f * density + visibleWindowFrame.top,
52
- visibleWindowFrame = visibleWindowFrame
53
- )
54
- )
55
- assertFalse(
56
- view.isPointInsideStandaloneToolbarForTesting(
57
- rawX = 30f * density,
58
- rawY = 50f * density,
59
- visibleWindowFrame = visibleWindowFrame
60
- )
61
- )
62
- assertFalse(
63
- view.isPointInsideStandaloneToolbarForTesting(
64
- rawX = 30f * density + visibleWindowFrame.left,
65
- rawY = 90f * density + visibleWindowFrame.top,
66
- visibleWindowFrame = visibleWindowFrame
67
- )
68
- )
69
- }
70
-
71
- @Test
72
- fun `toolbar focus preservation is inactive until a toolbar touch is recorded`() {
73
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
74
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
75
-
76
- assertFalse(view.shouldPreserveFocusAfterToolbarTouchForTesting())
77
-
78
- view.markRecentToolbarTouchForTesting()
79
- assertTrue(view.shouldPreserveFocusAfterToolbarTouchForTesting())
80
-
81
- view.blur()
82
- assertFalse(view.shouldPreserveFocusAfterToolbarTouchForTesting())
83
- }
84
-
85
- @Test
86
- fun `outside tap schedules native outside blur`() {
87
- val view = attachedNativeEditorView()
88
- val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 500f, 500f, 0)
89
-
90
- val decision = view.prepareOutsideTapDecisionForWindowEvent(event)
91
- view.handleOutsideTapDecisionFromWindowDispatcher(decision)
92
- event.recycle()
93
-
94
- assertEquals(NativeEditorOutsideTapDecision.OUTSIDE_EDITOR, decision)
95
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
96
- view.cancelOutsideTapBlurFromWindowDispatcher()
97
- }
98
-
99
- @Test
100
- fun `toolbar frame tap preserves focus before dispatch result`() {
101
- val view = attachedNativeEditorView()
102
- val density = view.context.resources.displayMetrics.density
103
- view.setToolbarFrameJson("""{"x":20,"y":40,"width":100,"height":32}""")
104
- view.scheduleOutsideTapBlurFromWindowDispatcher()
105
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
106
- val event = MotionEvent.obtain(
107
- 0L,
108
- 0L,
109
- MotionEvent.ACTION_DOWN,
110
- 30f * density,
111
- 50f * density,
112
- 0
113
- )
114
-
115
- val decision = view.prepareOutsideTapDecisionForWindowEvent(event)
116
- view.handleOutsideTapDecisionFromWindowDispatcher(decision)
117
- event.recycle()
118
-
119
- assertEquals(NativeEditorOutsideTapDecision.PRESERVE_FOCUS, decision)
120
- assertTrue(view.shouldPreserveFocusAfterToolbarTouchForTesting())
121
- assertFalse(view.hasPendingOutsideTapBlurForTesting())
122
- }
123
-
124
- @Test
125
- fun `outside tap clears stale toolbar focus preservation`() {
126
- val view = attachedNativeEditorView()
127
- view.markRecentToolbarTouchForTesting()
128
- assertTrue(view.shouldPreserveFocusAfterToolbarTouchForTesting())
129
-
130
- val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 500f, 500f, 0)
131
- val decision = view.prepareOutsideTapDecisionForWindowEvent(event)
132
- view.handleOutsideTapDecisionFromWindowDispatcher(decision)
133
- event.recycle()
134
-
135
- assertEquals(NativeEditorOutsideTapDecision.OUTSIDE_EDITOR, decision)
136
- assertFalse(view.shouldPreserveFocusAfterToolbarTouchForTesting())
137
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
138
- view.cancelOutsideTapBlurFromWindowDispatcher()
139
- }
140
-
141
- @Test
142
- fun `toolbar refocus does not cancel stale pending outside blur`() {
143
- val view = attachedNativeEditorView()
144
-
145
- view.scheduleOutsideTapBlurFromWindowDispatcher()
146
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
147
-
148
- view.focusFromToolbarPreserveForTesting()
149
-
150
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
151
- view.cancelOutsideTapBlurFromWindowDispatcher()
152
- }
153
-
154
- @Test
155
- fun `outside tap handler installs from app context current activity`() {
156
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
157
- val host = FrameLayout(activity)
158
- activity.setContentView(host)
159
- val expoContext = testExpoContext(
160
- RuntimeEnvironment.getApplication(),
161
- currentActivity = activity
162
- )
163
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
164
-
165
- view.onFocusChangeForTesting = {}
166
- view.onAddonEventForTesting = {}
167
- host.addView(view, FrameLayout.LayoutParams(200, 200))
168
- view.setAttachedToNativeWindowForTesting(true)
169
- view.setEditorFocusedForOutsideTapDecisionForTesting(true)
170
-
171
- try {
172
- view.installOutsideTapBlurHandlerForTesting()
173
- assertTrue(view.isOutsideTapBlurHandlerInstalledForTesting())
174
-
175
- val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 500f, 500f, 0)
176
- assertEquals(
177
- NativeEditorOutsideTapDecision.OUTSIDE_EDITOR,
178
- view.prepareOutsideTapDecisionForWindowEvent(event)
179
- )
180
- event.recycle()
181
- } finally {
182
- view.cancelOutsideTapBlurFromWindowDispatcher()
183
- view.uninstallOutsideTapBlurHandlerForTesting()
184
- }
185
- }
186
-
187
- @Test
188
- fun `detached registered view blocks command preflight until it reattaches`() {
189
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
190
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
191
- val editorId = 12345L
192
-
193
- NativeEditorViewRegistry.register(editorId, view)
194
- assertTrue(
195
- JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
196
- .getBoolean("ready")
197
- )
198
-
199
- NativeEditorViewRegistry.unregister(editorId, view, blockCommandsUntilRegistered = true)
200
-
201
- assertFalse(
202
- JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
203
- .getBoolean("ready")
204
- )
205
-
206
- NativeEditorViewRegistry.register(editorId, view)
207
-
208
- assertTrue(
209
- JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
210
- .getBoolean("ready")
211
- )
212
- NativeEditorViewRegistry.unregister(editorId, view)
213
- }
214
-
215
- @Test
216
- fun `non owner unregister does not clear detached command block`() {
217
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
218
- val ownerView = NativeEditorExpoView(expoContext.context, expoContext.appContext)
219
- val otherView = NativeEditorExpoView(expoContext.context, expoContext.appContext)
220
- val editorId = 22334L
221
-
222
- NativeEditorViewRegistry.register(editorId, ownerView)
223
- NativeEditorViewRegistry.unregister(
224
- editorId,
225
- ownerView,
226
- blockCommandsUntilRegistered = true
227
- )
228
- NativeEditorViewRegistry.unregister(editorId, otherView)
229
-
230
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
231
- assertFalse(preparation.getBoolean("ready"))
232
- assertEquals("detached", preparation.getString("blockedReason"))
233
-
234
- NativeEditorViewRegistry.register(editorId, ownerView)
235
- NativeEditorViewRegistry.unregister(editorId, ownerView)
236
- }
237
-
238
- @Test
239
- fun `editor id set while detached blocks command preflight without binding editor`() {
240
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
241
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
242
- val editorId = 23456L
243
-
244
- view.setEditorId(editorId)
245
-
246
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
247
- assertFalse(preparation.getBoolean("ready"))
248
- assertEquals("detached", preparation.getString("blockedReason"))
249
- assertEquals(editorId, view.richTextView.editorId)
250
- assertEquals(0L, view.richTextView.editorEditText.editorId)
251
-
252
- NativeEditorViewRegistry.unregister(editorId, view)
253
- }
254
-
255
- @Test
256
- fun `timed out off main command preflight does not flush composition later`() {
257
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
258
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
259
- val editorId = 24567L
260
- val editText = view.richTextView.editorEditText
261
- var insertedText: String? = null
262
-
263
- view.richTextView.setEditorIdWhileDetached(editorId)
264
- editText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
265
- editText.setSelection(0)
266
- editText.editorId = editorId
267
- view.setAttachedToNativeWindowForTesting(true)
268
- editText.onInsertTextInRustForTesting = { text, _ ->
269
- insertedText = text
270
- }
271
- NativeEditorViewRegistry.register(editorId, view)
272
-
273
- val inputConnection = editText.onCreateInputConnection(android.view.inputmethod.EditorInfo())
274
- assertNotNull(inputConnection)
275
- assertTrue(inputConnection!!.setComposingText("abc", 1))
276
-
277
- val result = AtomicReference<String?>(null)
278
- val thread = Thread {
279
- result.set(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
280
- }
281
- thread.start()
282
- thread.join(1000)
283
-
284
- val preparation = JSONObject(result.get()!!)
285
- assertFalse(preparation.getBoolean("ready"))
286
- assertEquals("unknown", preparation.getString("blockedReason"))
287
-
288
- shadowOf(Looper.getMainLooper()).idle()
289
-
290
- assertNull(insertedText)
291
- NativeEditorViewRegistry.unregister(editorId, view)
292
- }
293
-
294
- @Test
295
- fun `off main command preflight waits for started side effecting preparation`() {
296
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
297
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
298
- val editorId = 24568L
299
-
300
- view.richTextView.setEditorIdWhileDetached(editorId)
301
- view.richTextView.editorEditText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
302
- view.richTextView.editorEditText.editorId = editorId
303
- view.setAttachedToNativeWindowForTesting(true)
304
- NativeEditorViewRegistry.register(editorId, view)
305
- val preparationStarted = AtomicBoolean(false)
306
- view.onBeforePrepareForEditorCommandForTesting = {
307
- preparationStarted.set(true)
308
- Thread.sleep(300)
309
- }
310
-
311
- val result = AtomicReference<String?>(null)
312
- val thread = Thread {
313
- result.set(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
314
- }
315
- thread.start()
316
- while (!preparationStarted.get() && result.get() == null) {
317
- shadowOf(Looper.getMainLooper()).idle()
318
- Thread.sleep(10)
319
- }
320
- thread.join(1000)
321
-
322
- assertFalse(thread.isAlive)
323
- val preparation = JSONObject(result.get()!!)
324
- assertTrue(preparation.getBoolean("ready"))
325
-
326
- NativeEditorViewRegistry.unregister(editorId, view)
327
- }
328
-
329
- @Test
330
- fun `detach preserves pending controlled editor update json`() {
331
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
332
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
333
- val updateJson = """{"renderElements":[],"selection":{"type":"text","anchor":0,"head":0}}"""
334
-
335
- view.setPendingEditorUpdateJson(updateJson)
336
- view.setPendingEditorUpdateRevision(1)
337
-
338
- view.handleDetachedFromWindowForTesting()
339
-
340
- assertEquals(updateJson, view.pendingEditorUpdateJsonForTesting())
341
- }
342
-
343
- @Test
344
- fun `editor id change preserves pending controlled update until matching update editor id arrives`() {
345
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
346
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
347
- val updateJson = """{"renderElements":[],"selection":{"type":"text","anchor":0,"head":0}}"""
348
-
349
- view.setPendingEditorUpdateJson(updateJson)
350
- view.setPendingEditorUpdateRevision(7)
351
- view.setEditorId(33445L)
352
-
353
- assertEquals(updateJson, view.pendingEditorUpdateJsonForTesting())
354
- assertEquals(7, view.pendingEditorUpdateRevisionForTesting())
355
- assertNull(view.pendingEditorUpdateEditorIdForTesting())
356
-
357
- view.setPendingEditorUpdateEditorId(33445L)
358
-
359
- assertEquals(33445L, view.pendingEditorUpdateEditorIdForTesting())
360
- assertEquals(updateJson, view.pendingEditorUpdateJsonForTesting())
361
-
362
- NativeEditorViewRegistry.unregister(33445L, view)
363
- }
364
-
365
- @Test
366
- fun `editor id change drops pending update scoped to a different editor`() {
367
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
368
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
369
- val updateJson = """{"renderElements":[],"selection":{"type":"text","anchor":0,"head":0}}"""
370
-
371
- view.setPendingEditorUpdateJson(updateJson)
372
- view.setPendingEditorUpdateEditorId(111L)
373
- view.setPendingEditorUpdateRevision(3)
374
-
375
- view.setEditorId(222L)
376
-
377
- assertNull(view.pendingEditorUpdateJsonForTesting())
378
- assertEquals(0, view.pendingEditorUpdateRevisionForTesting())
379
-
380
- NativeEditorViewRegistry.unregister(222L, view)
381
- }
382
-
383
- @Test
384
- fun `null controlled update clears queued update for matching editor`() {
385
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
386
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
387
- val updateJson = """{"renderElements":[],"selection":{"type":"text","anchor":0,"head":0}}"""
388
-
389
- view.richTextView.setEditorIdWhileDetached(55667L)
390
- view.setPendingEditorUpdateJson(updateJson)
391
- view.setPendingEditorUpdateEditorId(55667L)
392
- view.setPendingEditorUpdateRevision(1)
393
-
394
- view.setPendingEditorUpdateJson(null)
395
- view.setPendingEditorUpdateEditorId(55667L)
396
- view.setPendingEditorUpdateRevision(2)
397
- view.applyPendingEditorUpdateIfNeeded()
398
-
399
- assertNull(view.pendingEditorUpdateJsonForTesting())
400
- assertEquals(0, view.pendingEditorUpdateRevisionForTesting())
401
- }
402
-
403
- @Test
404
- fun `replayed applied controlled update revision clears stale pending state`() {
405
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
406
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
407
- val editorId = 55668L
408
- val replayedUpdateJson = renderUpdateJson("replayed")
409
- val editText = view.richTextView.editorEditText
410
- val readyPayloads = mutableListOf<Map<String, Any>>()
411
-
412
- view.richTextView.setEditorIdWhileDetached(editorId)
413
- editText.applyUpdateJSON(renderUpdateJson("first"), notifyListener = false)
414
- editText.setSelection(0)
415
- editText.editorId = editorId
416
- view.setAttachedToNativeWindowForTesting(true)
417
- view.onEditorReadyForTesting = { payload ->
418
- readyPayloads.add(payload)
419
- }
420
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
421
- view.onAddonEventForTesting = {}
422
- view.setAppliedEditorUpdateRevisionForTesting(1)
423
-
424
- view.setPendingEditorUpdateJson(replayedUpdateJson)
425
- view.setPendingEditorUpdateEditorId(editorId)
426
- view.setPendingEditorUpdateRevision(1)
427
- view.applyPendingEditorUpdateIfNeeded()
428
-
429
- assertNull(view.pendingEditorUpdateJsonForTesting())
430
- assertEquals(0, view.pendingEditorUpdateRevisionForTesting())
431
- assertEquals("first", editText.text?.toString())
432
- assertEquals(1, readyPayloads.size)
433
- assertEquals(1, readyPayloads.single()["editorUpdateRevision"])
434
- }
435
-
436
- @Test
437
- fun `editor id change resets last document version`() {
438
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
439
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
440
-
441
- view.setLastDocumentVersionForTesting(20)
442
-
443
- assertEquals(20, view.lastDocumentVersionForTesting())
444
-
445
- view.setEditorId(66778L)
446
-
447
- assertNull(view.lastDocumentVersionForTesting())
448
-
449
- NativeEditorViewRegistry.unregister(66778L, view)
450
- }
451
-
452
- @Test
453
- fun `selection toolbar refresh seeds document version for no update toolbar action`() {
454
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
455
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
456
- val editorId = 667781L
457
- val editText = view.richTextView.editorEditText
458
- var toolbarActionPayload: Map<String, Any>? = null
459
-
460
- view.richTextView.setEditorIdWhileDetached(editorId)
461
- editText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
462
- editText.setSelection(0)
463
- editText.editorId = editorId
464
- view.setAttachedToNativeWindowForTesting(true)
465
- view.onAddonEventForTesting = {}
466
- view.onRefreshToolbarStateFromEditorSelectionForTesting = {
467
- JSONObject(renderUpdateJson(""))
468
- .put("documentVersion", 7)
469
- .toString()
470
- }
471
- view.onToolbarActionForTesting = { payload ->
472
- toolbarActionPayload = payload
473
- }
474
-
475
- view.refreshToolbarStateFromEditorSelectionForTesting()
476
- view.handleToolbarItemPressForTesting(
477
- NativeToolbarItem(
478
- type = ToolbarItemKind.action,
479
- key = "custom",
480
- label = "Custom"
481
- )
482
- )
483
-
484
- assertEquals(7, view.lastDocumentVersionForTesting())
485
- assertEquals(7, toolbarActionPayload?.get("documentVersion"))
486
-
487
- NativeEditorViewRegistry.unregister(editorId, view)
488
- }
489
-
490
- @Test
491
- fun `autofocus requested before attach applies when editor becomes focusable`() {
492
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
493
- val parent = FrameLayout(activity)
494
- activity.setContentView(parent)
495
- val expoContext = testExpoContext(activity)
496
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
497
- val editorId = 66779L
498
- val editText = view.richTextView.editorEditText
499
-
500
- view.onAddonEventForTesting = {}
501
- view.onFocusChangeForTesting = {}
502
- view.setAutoFocus(true)
503
- parent.addView(view)
504
- view.richTextView.setEditorIdWhileDetached(editorId)
505
- editText.editorId = editorId
506
-
507
- assertFalse(editText.hasFocus())
508
-
509
- view.applyAutoFocusForTesting()
510
-
511
- assertTrue(editText.hasFocus())
512
-
513
- NativeEditorViewRegistry.unregister(editorId, view)
514
- }
515
-
516
- @Test
517
- fun `destroyed editor id invalidates registry and matching view`() {
518
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
519
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
520
- val editorId = 77881L
521
-
522
- NativeEditorViewRegistry.markEditorCreated(editorId)
523
- view.richTextView.setEditorIdWhileDetached(editorId)
524
- NativeEditorViewRegistry.register(editorId, view)
525
-
526
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
527
-
528
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
529
- assertFalse(preparation.getBoolean("ready"))
530
- assertEquals("destroyed", preparation.getString("blockedReason"))
531
- assertEquals(0L, view.richTextView.editorId)
532
- }
533
-
534
- @Test
535
- fun `destroyed editor id cannot register a new view`() {
536
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
537
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
538
- val editorId = 778811L
539
-
540
- NativeEditorViewRegistry.markEditorCreated(editorId)
541
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
542
-
543
- assertFalse(NativeEditorViewRegistry.register(editorId, view))
544
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
545
- assertFalse(preparation.getBoolean("ready"))
546
- assertEquals("destroyed", preparation.getString("blockedReason"))
547
- }
548
-
549
- @Test
550
- fun `destroyed editor invalidation from background waits for view cleanup`() {
551
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
552
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
553
- val editorId = 77882L
554
- val completed = AtomicBoolean(false)
555
-
556
- NativeEditorViewRegistry.markEditorCreated(editorId)
557
- view.richTextView.setEditorIdWhileDetached(editorId)
558
- NativeEditorViewRegistry.register(editorId, view)
559
-
560
- val thread = Thread {
561
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
562
- completed.set(true)
563
- }
564
- thread.start()
565
- shadowOf(Looper.getMainLooper()).idle()
566
- thread.join(1000)
567
- shadowOf(Looper.getMainLooper()).idle()
568
-
569
- assertFalse(thread.isAlive)
570
- assertTrue(completed.get())
571
- assertEquals(0L, view.richTextView.editorId)
572
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
573
- assertFalse(preparation.getBoolean("ready"))
574
- assertEquals("destroyed", preparation.getString("blockedReason"))
575
- }
576
-
577
- @Test
578
- fun `destroyed editor invalidation from background times out until main cleanup runs`() {
579
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
580
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
581
- val editorId = 77884L
582
- val completed = AtomicBoolean(false)
583
-
584
- NativeEditorViewRegistry.markEditorCreated(editorId)
585
- view.richTextView.setEditorIdWhileDetached(editorId)
586
- val editText = view.richTextView.editorEditText
587
- editText.applyUpdateJSON(renderUpdateJson("ready"), notifyListener = false)
588
- editText.setSelection(5)
589
- editText.editorId = editorId
590
- var insertedText: String? = null
591
- var syncedSelection: Pair<Int, Int>? = null
592
- editText.onInsertTextInRustForTesting = { text, _ -> insertedText = text }
593
- editText.onSetSelectionScalarInRustForTesting = { anchor, head ->
594
- syncedSelection = anchor to head
595
- }
596
- val inputConnection = editText.onCreateInputConnection(EditorInfo())
597
- assertNotNull(inputConnection)
598
- NativeEditorViewRegistry.register(editorId, view)
599
-
600
- val thread = Thread {
601
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
602
- completed.set(true)
603
- }
604
- thread.start()
605
- thread.join(1000)
606
-
607
- assertFalse(thread.isAlive)
608
- assertTrue(completed.get())
609
- assertEquals(editorId, view.richTextView.editorId)
610
- assertFalse(NativeEditorViewRegistry.register(editorId, view))
611
- assertTrue(inputConnection!!.commitText("x", 1))
612
- editText.setSelection(0)
613
- assertNull(insertedText)
614
- assertNull(syncedSelection)
615
-
616
- shadowOf(Looper.getMainLooper()).idle()
617
- assertEquals(0L, view.richTextView.editorId)
618
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
619
- assertFalse(preparation.getBoolean("ready"))
620
- assertEquals("destroyed", preparation.getString("blockedReason"))
621
- }
622
-
623
- @Test
624
- fun `cleared detached weak owner does not block command preflight forever`() {
625
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
626
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
627
- val editorId = 77883L
628
-
629
- NativeEditorViewRegistry.register(editorId, view)
630
- NativeEditorViewRegistry.unregister(
631
- editorId,
632
- view,
633
- blockCommandsUntilRegistered = true
634
- )
635
- NativeEditorViewRegistry.forceDetachedOwnerClearedForTesting(editorId)
636
-
637
- val preparation = JSONObject(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
638
- assertTrue(preparation.getBoolean("ready"))
639
- }
640
-
641
- @Test
642
- fun `pending controlled update blocks command preflight`() {
643
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
644
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
645
- val editorId = 77884L
646
- val updateJson = renderUpdateJson("")
647
-
648
- view.richTextView.setEditorIdWhileDetached(editorId)
649
- view.richTextView.editorEditText.editorId = editorId
650
- view.setAttachedToNativeWindowForTesting(true)
651
- view.setPendingEditorUpdateJson(updateJson)
652
- view.setPendingEditorUpdateEditorId(editorId)
653
- view.setPendingEditorUpdateRevision(1)
654
-
655
- val preparation = JSONObject(view.prepareForEditorCommandJSON())
656
-
657
- assertFalse(preparation.getBoolean("ready"))
658
- assertEquals("pendingUpdate", preparation.getString("blockedReason"))
659
-
660
- NativeEditorViewRegistry.unregister(editorId, view)
661
- }
662
-
663
- @Test
664
- fun `pending controlled update keeps retrying after fast retry budget`() {
665
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
666
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
667
- val editorId = 778842L
668
- val editText = view.richTextView.editorEditText
669
- val updateJson = renderUpdateJson("recovered")
670
- val readyPayloads = mutableListOf<Map<String, Any>>()
671
-
672
- view.richTextView.setEditorIdWhileDetached(editorId)
673
- editText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
674
- editText.setSelection(0)
675
- editText.editorId = editorId
676
- view.setAttachedToNativeWindowForTesting(true)
677
- view.blockEditorUpdatePreflightForTesting = true
678
- view.onEditorReadyForTesting = { payload ->
679
- readyPayloads.add(payload)
680
- }
681
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
682
- view.setPendingEditorUpdateJson(updateJson)
683
- view.setPendingEditorUpdateEditorId(editorId)
684
- view.setPendingEditorUpdateRevision(9)
685
-
686
- view.applyPendingEditorUpdateIfNeeded()
687
- repeat(6) {
688
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
689
- }
690
-
691
- assertEquals(updateJson, view.pendingEditorUpdateJsonForTesting())
692
-
693
- view.blockEditorUpdatePreflightForTesting = false
694
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1000))
695
-
696
- assertNull(view.pendingEditorUpdateJsonForTesting())
697
- assertEquals(9, readyPayloads.last()["editorUpdateRevision"])
698
-
699
- NativeEditorViewRegistry.unregister(editorId, view)
700
- }
701
-
702
- @Test
703
- fun `successful JS editor update clears queued native update events`() {
704
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
705
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
706
- val editorId = 778843L
707
- val editText = view.richTextView.editorEditText
708
- val nativeUpdateJson = renderUpdateJson("native")
709
- val jsUpdateJson = renderUpdateJson("controlled")
710
-
711
- view.onAddonEventForTesting = {}
712
- view.richTextView.setEditorIdWhileDetached(editorId)
713
- editText.applyUpdateJSON(renderUpdateJson("initial"), notifyListener = false)
714
- editText.setSelection(editText.text?.length ?: 0)
715
- editText.onSetSelectionScalarInRustForTesting = { _, _ -> }
716
- editText.editorId = editorId
717
- view.setAttachedToNativeWindowForTesting(true)
718
-
719
- view.onEditorUpdate(nativeUpdateJson)
720
-
721
- assertEquals(1, view.pendingEditorUpdateEventCountForTesting())
722
-
723
- val applied = AtomicBoolean(false)
724
- Handler(Looper.getMainLooper()).post {
725
- applied.set(view.applyEditorUpdate(jsUpdateJson))
726
- }
727
- shadowOf(Looper.getMainLooper()).idle()
728
-
729
- assertTrue(applied.get())
730
- assertEquals(0, view.pendingEditorUpdateEventCountForTesting())
731
- assertEquals("controlled", editText.text?.toString())
732
-
733
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
734
-
735
- assertEquals(0, view.pendingEditorUpdateEventCountForTesting())
736
- assertTrue(
737
- editText.imeTraceSnapshotForTesting().any {
738
- it.contains("nativeViewEditorUpdateQueueCleared")
739
- }
740
- )
741
-
742
- NativeEditorViewRegistry.unregister(editorId, view)
743
- }
744
-
745
- @Test
746
- fun `JS editor reset update bypasses preflight and clears stale pending updates`() {
747
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
748
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
749
- val editorId = 778844L
750
- val editText = view.richTextView.editorEditText
751
- val staleUpdateJson = renderUpdateJson("stale")
752
- val resetUpdateJson = renderUpdateJson("reset")
753
-
754
- view.onAddonEventForTesting = {}
755
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
756
- view.onEditorReadyForTesting = {}
757
- view.richTextView.setEditorIdWhileDetached(editorId)
758
- editText.applyUpdateJSON(renderUpdateJson("before"), notifyListener = false)
759
- editText.setSelection(editText.text?.length ?: 0)
760
- editText.editorId = editorId
761
- view.setAttachedToNativeWindowForTesting(true)
762
- view.setPendingEditorUpdateJson(staleUpdateJson)
763
- view.setPendingEditorUpdateEditorId(editorId)
764
- view.setPendingEditorUpdateRevision(8)
765
- view.scheduleViewCommandUpdateRetryForTesting(staleUpdateJson)
766
- view.onEditorUpdate(staleUpdateJson)
767
- view.blockEditorUpdatePreflightForTesting = true
768
-
769
- assertEquals(editorId, view.richTextView.editorId)
770
- assertEquals(editorId, editText.editorId)
771
- val editTextShadow = shadowOf(editText)
772
- editTextShadow.clearWasInvalidated()
773
- val applied = AtomicBoolean(false)
774
- Handler(Looper.getMainLooper()).post {
775
- applied.set(view.applyEditorResetUpdate(resetUpdateJson))
776
- }
777
- shadowOf(Looper.getMainLooper()).idle()
778
-
779
- assertTrue(applied.get())
780
- assertEquals("reset", editText.text?.toString())
781
- assertTrue(editTextShadow.wasInvalidated())
782
- assertNull(view.pendingEditorUpdateJsonForTesting())
783
- assertEquals(0, view.pendingEditorUpdateRevisionForTesting())
784
- assertNull(view.pendingViewCommandUpdateJsonForTesting())
785
- assertEquals(0, view.pendingEditorUpdateEventCountForTesting())
786
-
787
- NativeEditorViewRegistry.unregister(editorId, view)
788
- }
789
-
790
- @Test
791
- fun `pending JS editor reset prop applies through reset path and clears stale pending updates`() {
792
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
793
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
794
- val editorId = 778845L
795
- val editText = view.richTextView.editorEditText
796
- val staleUpdateJson = renderUpdateJson("stale")
797
- val resetUpdateJson = renderUpdateJson("")
798
-
799
- view.onAddonEventForTesting = {}
800
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
801
- view.onEditorReadyForTesting = {}
802
- view.richTextView.setEditorIdWhileDetached(editorId)
803
- editText.applyUpdateJSON(renderUpdateJson("before"), notifyListener = false)
804
- editText.setSelection(editText.text?.length ?: 0)
805
- editText.editorId = editorId
806
- view.setAttachedToNativeWindowForTesting(true)
807
- view.setPendingEditorUpdateJson(staleUpdateJson)
808
- view.setPendingEditorUpdateEditorId(editorId)
809
- view.setPendingEditorUpdateRevision(8)
810
- view.scheduleViewCommandUpdateRetryForTesting(staleUpdateJson)
811
- view.onEditorUpdate(staleUpdateJson)
812
- view.setPendingEditorResetUpdateJson(resetUpdateJson)
813
- view.setPendingEditorResetUpdateEditorId(editorId)
814
- view.setPendingEditorResetUpdateRevision(9)
815
- view.blockEditorUpdatePreflightForTesting = true
816
- val editTextShadow = shadowOf(editText)
817
- editTextShadow.clearWasInvalidated()
818
-
819
- Handler(Looper.getMainLooper()).post {
820
- view.applyPendingEditorResetUpdateIfNeeded()
821
- }
822
- shadowOf(Looper.getMainLooper()).idle()
823
-
824
- assertEquals("", editText.text?.toString())
825
- assertTrue(editTextShadow.wasInvalidated())
826
- assertNull(view.pendingEditorResetUpdateJsonForTesting())
827
- assertEquals(0, view.pendingEditorResetUpdateRevisionForTesting())
828
- assertNull(view.pendingEditorUpdateJsonForTesting())
829
- assertEquals(0, view.pendingEditorUpdateRevisionForTesting())
830
- assertNull(view.pendingViewCommandUpdateJsonForTesting())
831
- assertEquals(0, view.pendingEditorUpdateEventCountForTesting())
832
-
833
- NativeEditorViewRegistry.unregister(editorId, view)
834
- }
835
-
836
- @Test
837
- fun `pending JS editor reset prop retries when editor view is not ready`() {
838
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
839
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
840
- val editorId = 778846L
841
- val editText = view.richTextView.editorEditText
842
- val resetUpdateJson = renderUpdateJson("")
843
-
844
- view.onAddonEventForTesting = {}
845
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
846
- view.onEditorReadyForTesting = {}
847
- view.richTextView.setEditorIdWhileDetached(editorId)
848
- editText.applyUpdateJSON(renderUpdateJson("before"), notifyListener = false)
849
- editText.editorId = 0L
850
- view.setAttachedToNativeWindowForTesting(true)
851
- view.setPendingEditorResetUpdateJson(resetUpdateJson)
852
- view.setPendingEditorResetUpdateEditorId(editorId)
853
- view.setPendingEditorResetUpdateRevision(10)
854
-
855
- Handler(Looper.getMainLooper()).post {
856
- view.applyPendingEditorResetUpdateIfNeeded()
857
- }
858
- shadowOf(Looper.getMainLooper()).idle()
859
-
860
- assertEquals("before", editText.text?.toString())
861
- assertEquals(resetUpdateJson, view.pendingEditorResetUpdateJsonForTesting())
862
-
863
- editText.editorId = editorId
864
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
865
-
866
- assertEquals("", editText.text?.toString())
867
- assertNull(view.pendingEditorResetUpdateJsonForTesting())
868
- assertEquals(0, view.pendingEditorResetUpdateRevisionForTesting())
869
-
870
- NativeEditorViewRegistry.unregister(editorId, view)
871
- }
872
-
873
- @Test
874
- fun `pending JS editor reset prop applies again when only revision changes`() {
875
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
876
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
877
- val editorId = 778847L
878
- val editText = view.richTextView.editorEditText
879
- val resetUpdateJson = renderUpdateJson("")
880
-
881
- view.onAddonEventForTesting = {}
882
- view.onRefreshToolbarStateFromEditorSelectionForTesting = { null }
883
- view.onEditorReadyForTesting = {}
884
- view.richTextView.setEditorIdWhileDetached(editorId)
885
- editText.editorId = editorId
886
- view.setAttachedToNativeWindowForTesting(true)
887
- editText.applyUpdateJSON(renderUpdateJson("first"), notifyListener = false)
888
- view.setPendingEditorResetUpdateJson(resetUpdateJson)
889
- view.setPendingEditorResetUpdateEditorId(editorId)
890
- view.setPendingEditorResetUpdateRevision(1)
891
- view.applyPendingEditorResetUpdateIfNeeded()
892
-
893
- assertEquals("", editText.text?.toString())
894
- assertNull(view.pendingEditorResetUpdateJsonForTesting())
895
-
896
- editText.applyUpdateJSON(renderUpdateJson("second"), notifyListener = false)
897
- view.setPendingEditorResetUpdateRevision(2)
898
- view.applyPendingEditorResetUpdateIfNeeded()
899
-
900
- assertEquals("", editText.text?.toString())
901
- assertNull(view.pendingEditorResetUpdateJsonForTesting())
902
- assertEquals(0, view.pendingEditorResetUpdateRevisionForTesting())
903
-
904
- NativeEditorViewRegistry.unregister(editorId, view)
905
- }
906
-
907
- @Test
908
- fun `editor ready payload includes acknowledged update revision`() {
909
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
910
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
911
- val editorId = 778841L
912
- val readyPayloads = mutableListOf<Map<String, Any>>()
913
-
914
- view.richTextView.setEditorIdWhileDetached(editorId)
915
- view.richTextView.editorEditText.editorId = editorId
916
- view.setAttachedToNativeWindowForTesting(true)
917
- view.onEditorReadyForTesting = { payload ->
918
- readyPayloads.add(payload)
919
- }
920
-
921
- assertTrue(view.emitEditorReadyForTesting(editorUpdateRevision = 4))
922
-
923
- assertEquals(1, readyPayloads.size)
924
- assertEquals(editorId, readyPayloads.single()["editorId"])
925
- assertEquals(4, readyPayloads.single()["editorUpdateRevision"])
926
-
927
- NativeEditorViewRegistry.unregister(editorId, view)
928
- }
929
-
930
- @Test
931
- fun `editor ready is suppressed while reset update is pending`() {
932
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
933
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
934
- val editorId = 778848L
935
- val readyPayloads = mutableListOf<Map<String, Any>>()
936
-
937
- view.richTextView.setEditorIdWhileDetached(editorId)
938
- view.richTextView.editorEditText.editorId = editorId
939
- view.setAttachedToNativeWindowForTesting(true)
940
- view.setPendingEditorResetUpdateJson(renderUpdateJson(""))
941
- view.setPendingEditorResetUpdateEditorId(editorId)
942
- view.setPendingEditorResetUpdateRevision(12)
943
- view.onEditorReadyForTesting = { payload ->
944
- readyPayloads.add(payload)
945
- }
946
-
947
- assertFalse(view.emitEditorReadyForTesting(editorUpdateRevision = 12))
948
- assertTrue(readyPayloads.isEmpty())
949
-
950
- NativeEditorViewRegistry.unregister(editorId, view)
951
- }
952
-
953
- @Test
954
- fun `pending controlled update parks native toolbar action until cleared`() {
955
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
956
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
957
- val editorId = 77885L
958
- val editText = view.richTextView.editorEditText
959
- val updateJson = renderUpdateJson("")
960
- var toolbarActionPayload: Map<String, Any>? = null
961
-
962
- view.richTextView.setEditorIdWhileDetached(editorId)
963
- editText.applyUpdateJSON(updateJson, notifyListener = false)
964
- editText.setSelection(0)
965
- editText.editorId = editorId
966
- view.setAttachedToNativeWindowForTesting(true)
967
- view.setPendingEditorUpdateJson(updateJson)
968
- view.setPendingEditorUpdateEditorId(editorId)
969
- view.setPendingEditorUpdateRevision(1)
970
- view.onToolbarActionForTesting = { payload ->
971
- toolbarActionPayload = payload
972
- }
973
-
974
- val action = NativeToolbarItem(
975
- type = ToolbarItemKind.action,
976
- key = "custom",
977
- label = "Custom"
978
- )
979
-
980
- view.handleToolbarItemPressForTesting(action)
981
-
982
- assertTrue(view.hasPendingNativeActionForTesting())
983
- assertNull(toolbarActionPayload)
984
-
985
- view.setPendingEditorUpdateJson(null)
986
- view.setPendingEditorUpdateEditorId(editorId)
987
- view.setPendingEditorUpdateRevision(2)
988
- view.wakePendingPreflightWorkForTesting()
989
-
990
- assertFalse(view.hasPendingNativeActionForTesting())
991
- assertEquals("custom", toolbarActionPayload?.get("key"))
992
-
993
- NativeEditorViewRegistry.unregister(editorId, view)
994
- }
995
-
996
- @Test
997
- fun `parked native toolbar action survives controlled update document version acknowledgement`() {
998
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
999
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1000
- val editorId = 779855L
1001
- val editText = view.richTextView.editorEditText
1002
- val updateJson = renderUpdateJson("")
1003
- val acknowledgedUpdateJson = JSONObject(updateJson)
1004
- .put("documentVersion", 2)
1005
- .toString()
1006
- var toolbarActionPayload: Map<String, Any>? = null
1007
-
1008
- view.richTextView.setEditorIdWhileDetached(editorId)
1009
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1010
- editText.setSelection(0)
1011
- editText.editorId = editorId
1012
- view.setAttachedToNativeWindowForTesting(true)
1013
- view.setLastDocumentVersionForTesting(1)
1014
- view.onAddonEventForTesting = {}
1015
- view.setPendingEditorUpdateJson(acknowledgedUpdateJson)
1016
- view.setPendingEditorUpdateEditorId(editorId)
1017
- view.setPendingEditorUpdateRevision(1)
1018
- view.onToolbarActionForTesting = { payload ->
1019
- toolbarActionPayload = payload
1020
- }
1021
-
1022
- view.handleToolbarItemPressForTesting(
1023
- NativeToolbarItem(
1024
- type = ToolbarItemKind.action,
1025
- key = "custom",
1026
- label = "Custom"
1027
- )
1028
- )
1029
-
1030
- assertTrue(view.hasPendingNativeActionForTesting())
1031
-
1032
- view.isApplyingJSUpdate = true
1033
- view.onEditorUpdate(acknowledgedUpdateJson)
1034
- view.isApplyingJSUpdate = false
1035
-
1036
- assertTrue(view.hasPendingNativeActionForTesting())
1037
-
1038
- view.setPendingEditorUpdateJson(null)
1039
- view.setPendingEditorUpdateEditorId(editorId)
1040
- view.setPendingEditorUpdateRevision(2)
1041
- view.wakePendingPreflightWorkForTesting()
1042
-
1043
- assertFalse(view.hasPendingNativeActionForTesting())
1044
- assertEquals("custom", toolbarActionPayload?.get("key"))
1045
- assertEquals(2, toolbarActionPayload?.get("documentVersion"))
1046
-
1047
- NativeEditorViewRegistry.unregister(editorId, view)
1048
- }
1049
-
1050
- @Test
1051
- fun `parked native toolbar action is dropped when unrelated document version changes`() {
1052
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1053
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1054
- val editorId = 779857L
1055
- val editText = view.richTextView.editorEditText
1056
- val updateJson = renderUpdateJson("")
1057
- val acknowledgedUpdateJson = JSONObject(updateJson)
1058
- .put("documentVersion", 2)
1059
- .toString()
1060
- val unrelatedUpdateJson = JSONObject(updateJson)
1061
- .put("documentVersion", 3)
1062
- .toString()
1063
- var toolbarActionPayload: Map<String, Any>? = null
1064
-
1065
- view.richTextView.setEditorIdWhileDetached(editorId)
1066
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1067
- editText.setSelection(0)
1068
- editText.editorId = editorId
1069
- view.setAttachedToNativeWindowForTesting(true)
1070
- view.setLastDocumentVersionForTesting(1)
1071
- view.setPendingEditorUpdateJson(acknowledgedUpdateJson)
1072
- view.setPendingEditorUpdateEditorId(editorId)
1073
- view.setPendingEditorUpdateRevision(1)
1074
- view.onAddonEventForTesting = {}
1075
- view.onToolbarActionForTesting = { payload ->
1076
- toolbarActionPayload = payload
1077
- }
1078
-
1079
- view.handleToolbarItemPressForTesting(
1080
- NativeToolbarItem(
1081
- type = ToolbarItemKind.action,
1082
- key = "custom",
1083
- label = "Custom"
1084
- )
1085
- )
1086
-
1087
- assertTrue(view.hasPendingNativeActionForTesting())
1088
-
1089
- view.isApplyingJSUpdate = true
1090
- view.onEditorUpdate(unrelatedUpdateJson)
1091
- view.isApplyingJSUpdate = false
1092
-
1093
- assertFalse(view.hasPendingNativeActionForTesting())
1094
-
1095
- view.setPendingEditorUpdateJson(null)
1096
- view.setPendingEditorUpdateEditorId(editorId)
1097
- view.setPendingEditorUpdateRevision(2)
1098
- view.wakePendingPreflightWorkForTesting()
1099
-
1100
- assertNull(toolbarActionPayload)
1101
-
1102
- NativeEditorViewRegistry.unregister(editorId, view)
1103
- }
1104
-
1105
- @Test
1106
- fun `parked native mention selection survives controlled update document version acknowledgement`() {
1107
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1108
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1109
- val editorId = 779856L
1110
- val editText = view.richTextView.editorEditText
1111
- val updateJson = renderUpdateJson("Hi @ali")
1112
- val acknowledgedUpdateJson = JSONObject(updateJson)
1113
- .put("documentVersion", 2)
1114
- .toString()
1115
- val suggestion = NativeMentionSuggestion(
1116
- key = "u1",
1117
- title = "Alice",
1118
- subtitle = null,
1119
- label = "@Alice",
1120
- attrs = JSONObject().put("id", "u1")
1121
- )
1122
- var addonPayload: Map<String, Any>? = null
1123
-
1124
- view.richTextView.setEditorIdWhileDetached(editorId)
1125
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1126
- editText.setSelection(7)
1127
- editText.editorId = editorId
1128
- view.setAttachedToNativeWindowForTesting(true)
1129
- view.setLastDocumentVersionForTesting(1)
1130
- view.onAddonEventForTesting = { payload ->
1131
- addonPayload = payload
1132
- }
1133
- view.setAddonsJson(
1134
- JSONObject()
1135
- .put(
1136
- "mentions",
1137
- JSONObject()
1138
- .put("resolveSelectionAttrs", true)
1139
- .put(
1140
- "suggestions",
1141
- JSONArray().put(
1142
- JSONObject()
1143
- .put("key", "u1")
1144
- .put("title", "Alice")
1145
- .put("label", "@Alice")
1146
- .put("attrs", JSONObject().put("id", "u1"))
1147
- )
1148
- )
1149
- )
1150
- .toString()
1151
- )
1152
- addonPayload = null
1153
- view.setPendingEditorUpdateJson(acknowledgedUpdateJson)
1154
- view.setPendingEditorUpdateEditorId(editorId)
1155
- view.setPendingEditorUpdateRevision(1)
1156
-
1157
- view.insertMentionSuggestionForTesting(suggestion)
1158
-
1159
- assertTrue(view.hasPendingNativeActionForTesting())
1160
- assertNull(addonPayload)
1161
-
1162
- view.isApplyingJSUpdate = true
1163
- view.onEditorUpdate(acknowledgedUpdateJson)
1164
- view.isApplyingJSUpdate = false
1165
-
1166
- assertTrue(view.hasPendingNativeActionForTesting())
1167
-
1168
- view.setPendingEditorUpdateJson(null)
1169
- view.setPendingEditorUpdateEditorId(editorId)
1170
- view.setPendingEditorUpdateRevision(2)
1171
- addonPayload = null
1172
- view.wakePendingPreflightWorkForTesting()
1173
-
1174
- assertFalse(view.hasPendingNativeActionForTesting())
1175
- val eventJson = JSONObject(addonPayload?.get("eventJson") as String)
1176
- assertEquals("mentionsSelectRequest", eventJson.getString("type"))
1177
- assertEquals("u1", eventJson.getString("suggestionKey"))
1178
- assertEquals(2, eventJson.getInt("documentVersion"))
1179
-
1180
- NativeEditorViewRegistry.unregister(editorId, view)
1181
- }
1182
-
1183
- @Test
1184
- fun `destroyed editor clears parked native toolbar action without emitting callback`() {
1185
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1186
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1187
- val editorId = 779853L
1188
- val editText = view.richTextView.editorEditText
1189
- val updateJson = renderUpdateJson("")
1190
- var toolbarActionPayload: Map<String, Any>? = null
1191
-
1192
- view.richTextView.setEditorIdWhileDetached(editorId)
1193
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1194
- editText.setSelection(0)
1195
- editText.editorId = editorId
1196
- view.setAttachedToNativeWindowForTesting(true)
1197
- view.setPendingEditorUpdateJson(updateJson)
1198
- view.setPendingEditorUpdateEditorId(editorId)
1199
- view.setPendingEditorUpdateRevision(1)
1200
- view.onToolbarActionForTesting = { payload ->
1201
- toolbarActionPayload = payload
1202
- }
1203
-
1204
- view.handleToolbarItemPressForTesting(
1205
- NativeToolbarItem(
1206
- type = ToolbarItemKind.action,
1207
- key = "custom",
1208
- label = "Custom"
1209
- )
1210
- )
1211
-
1212
- assertTrue(view.hasPendingNativeActionForTesting())
1213
-
1214
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
1215
- view.setPendingEditorUpdateJson(null)
1216
- view.setPendingEditorUpdateEditorId(editorId)
1217
- view.setPendingEditorUpdateRevision(2)
1218
- view.wakePendingPreflightWorkForTesting()
1219
-
1220
- assertFalse(view.hasPendingNativeActionForTesting())
1221
- assertNull(toolbarActionPayload)
1222
- }
1223
-
1224
- @Test
1225
- fun `toolbar visibility placement and editability changes clear parked native toolbar action`() {
1226
- val cases = listOf<(NativeEditorExpoView) -> Unit>(
1227
- { view -> view.setShowToolbar(false) },
1228
- { view -> view.setToolbarPlacement("inline") },
1229
- { view -> view.setEditable(false) }
1230
- )
1231
-
1232
- cases.forEachIndexed { index, clearAction ->
1233
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1234
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1235
- val editorId = 778852L + index
1236
- val editText = view.richTextView.editorEditText
1237
- val updateJson = renderUpdateJson("")
1238
- var toolbarActionPayload: Map<String, Any>? = null
1239
-
1240
- view.richTextView.setEditorIdWhileDetached(editorId)
1241
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1242
- editText.setSelection(0)
1243
- editText.editorId = editorId
1244
- view.setAttachedToNativeWindowForTesting(true)
1245
- view.setPendingEditorUpdateJson(updateJson)
1246
- view.setPendingEditorUpdateEditorId(editorId)
1247
- view.setPendingEditorUpdateRevision(1)
1248
- view.onToolbarActionForTesting = { payload ->
1249
- toolbarActionPayload = payload
1250
- }
1251
-
1252
- view.handleToolbarItemPressForTesting(
1253
- NativeToolbarItem(
1254
- type = ToolbarItemKind.action,
1255
- key = "custom",
1256
- label = "Custom"
1257
- )
1258
- )
1259
-
1260
- assertTrue(view.hasPendingNativeActionForTesting())
1261
-
1262
- clearAction(view)
1263
- view.setPendingEditorUpdateJson(null)
1264
- view.setPendingEditorUpdateEditorId(editorId)
1265
- view.setPendingEditorUpdateRevision(2)
1266
- view.wakePendingPreflightWorkForTesting()
1267
-
1268
- assertFalse(view.hasPendingNativeActionForTesting())
1269
- assertNull(toolbarActionPayload)
1270
-
1271
- NativeEditorViewRegistry.unregister(editorId, view)
1272
- }
1273
- }
1274
-
1275
- @Test
1276
- fun `real blur clears parked native toolbar action`() {
1277
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
1278
- val host = FrameLayout(activity)
1279
- activity.setContentView(host)
1280
- val expoContext = testExpoContext(activity)
1281
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1282
- val editorId = 778856L
1283
- val editText = view.richTextView.editorEditText
1284
- val updateJson = renderUpdateJson("")
1285
- var toolbarActionPayload: Map<String, Any>? = null
1286
-
1287
- host.addView(view)
1288
- view.richTextView.setEditorIdWhileDetached(editorId)
1289
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1290
- editText.setSelection(0)
1291
- editText.editorId = editorId
1292
- view.setAttachedToNativeWindowForTesting(true)
1293
- view.setCurrentImeBottomForTesting(120)
1294
- view.onAddonEventForTesting = {}
1295
- view.onFocusChangeForTesting = {}
1296
- view.onToolbarActionForTesting = { payload ->
1297
- toolbarActionPayload = payload
1298
- }
1299
- assertTrue(editText.requestFocus())
1300
- shadowOf(Looper.getMainLooper()).idle()
1301
-
1302
- view.setPendingEditorUpdateJson(updateJson)
1303
- view.setPendingEditorUpdateEditorId(editorId)
1304
- view.setPendingEditorUpdateRevision(1)
1305
- view.handleToolbarItemPressForTesting(
1306
- NativeToolbarItem(
1307
- type = ToolbarItemKind.action,
1308
- key = "custom",
1309
- label = "Custom"
1310
- )
1311
- )
1312
-
1313
- assertTrue(view.hasPendingNativeActionForTesting())
1314
-
1315
- editText.clearFocus()
1316
- shadowOf(Looper.getMainLooper()).idle()
1317
- view.setPendingEditorUpdateJson(null)
1318
- view.setPendingEditorUpdateEditorId(editorId)
1319
- view.setPendingEditorUpdateRevision(2)
1320
- view.wakePendingPreflightWorkForTesting()
1321
-
1322
- assertFalse(view.hasPendingNativeActionForTesting())
1323
- assertNull(toolbarActionPayload)
1324
-
1325
- NativeEditorViewRegistry.unregister(editorId, view)
1326
- }
1327
-
1328
- @Test
1329
- fun `toolbar preserved blur keeps parked native toolbar action current while refocus is pending`() {
1330
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1331
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1332
- val editorId = 778857L
1333
- val editText = view.richTextView.editorEditText
1334
- val updateJson = renderUpdateJson("")
1335
- var toolbarActionPayload: Map<String, Any>? = null
1336
-
1337
- view.richTextView.setEditorIdWhileDetached(editorId)
1338
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1339
- editText.setSelection(0)
1340
- editText.editorId = editorId
1341
- view.setAttachedToNativeWindowForTesting(true)
1342
- view.setCurrentImeBottomForTesting(120)
1343
- view.onFocusChangeForTesting = {}
1344
- view.onToolbarActionForTesting = { payload ->
1345
- toolbarActionPayload = payload
1346
- }
1347
- view.scheduleToolbarRefocusForTesting()
1348
- assertTrue(view.hasPendingToolbarRefocusForTesting())
1349
-
1350
- view.setPendingEditorUpdateJson(updateJson)
1351
- view.setPendingEditorUpdateEditorId(editorId)
1352
- view.setPendingEditorUpdateRevision(1)
1353
- view.handleToolbarItemPressForTesting(
1354
- NativeToolbarItem(
1355
- type = ToolbarItemKind.action,
1356
- key = "custom",
1357
- label = "Custom"
1358
- )
1359
- )
1360
-
1361
- assertTrue(view.hasPendingNativeActionForTesting())
1362
-
1363
- view.setPendingEditorUpdateJson(null)
1364
- view.setPendingEditorUpdateEditorId(editorId)
1365
- view.setPendingEditorUpdateRevision(2)
1366
- view.wakePendingPreflightWorkForTesting()
1367
-
1368
- assertFalse(view.hasPendingNativeActionForTesting())
1369
- assertEquals("custom", toolbarActionPayload?.get("key"))
1370
-
1371
- NativeEditorViewRegistry.unregister(editorId, view)
1372
- }
1373
-
1374
- @Test
1375
- fun `keyboard toolbar becoming invisible clears parked native toolbar action`() {
1376
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1377
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1378
- val editorId = 778858L
1379
- val editText = view.richTextView.editorEditText
1380
- val updateJson = renderUpdateJson("")
1381
- var toolbarActionPayload: Map<String, Any>? = null
1382
-
1383
- view.richTextView.setEditorIdWhileDetached(editorId)
1384
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1385
- editText.setSelection(0)
1386
- editText.editorId = editorId
1387
- view.setAttachedToNativeWindowForTesting(true)
1388
- view.setPendingEditorUpdateJson(updateJson)
1389
- view.setPendingEditorUpdateEditorId(editorId)
1390
- view.setPendingEditorUpdateRevision(1)
1391
- view.onToolbarActionForTesting = { payload ->
1392
- toolbarActionPayload = payload
1393
- }
1394
-
1395
- view.handleToolbarItemPressForTesting(
1396
- NativeToolbarItem(
1397
- type = ToolbarItemKind.action,
1398
- key = "custom",
1399
- label = "Custom"
1400
- )
1401
- )
1402
-
1403
- assertTrue(view.hasPendingNativeActionForTesting())
1404
-
1405
- view.setCurrentImeBottomForTesting(0)
1406
- view.updateAttachedKeyboardToolbarForInsetsForTesting()
1407
- view.setPendingEditorUpdateJson(null)
1408
- view.setPendingEditorUpdateEditorId(editorId)
1409
- view.setPendingEditorUpdateRevision(2)
1410
- view.wakePendingPreflightWorkForTesting()
1411
-
1412
- assertFalse(view.hasPendingNativeActionForTesting())
1413
- assertNull(toolbarActionPayload)
1414
-
1415
- NativeEditorViewRegistry.unregister(editorId, view)
1416
- }
1417
-
1418
- @Test
1419
- fun `read only native toolbar and mention callbacks are consumed without mutation`() {
1420
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1421
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1422
- val editorId = 778859L
1423
- val editText = view.richTextView.editorEditText
1424
- val updateJson = renderUpdateJson("Hi @ali")
1425
- val suggestion = NativeMentionSuggestion(
1426
- key = "u1",
1427
- title = "Alice",
1428
- subtitle = null,
1429
- label = "@Alice",
1430
- attrs = JSONObject().put("id", "u1")
1431
- )
1432
- var toolbarActionPayload: Map<String, Any>? = null
1433
- var addonPayload: Map<String, Any>? = null
1434
-
1435
- view.richTextView.setEditorIdWhileDetached(editorId)
1436
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1437
- editText.setSelection(7)
1438
- editText.editorId = editorId
1439
- view.setAttachedToNativeWindowForTesting(true)
1440
- view.onAddonEventForTesting = { payload ->
1441
- addonPayload = payload
1442
- }
1443
- view.setAddonsJson(
1444
- JSONObject()
1445
- .put(
1446
- "mentions",
1447
- JSONObject()
1448
- .put("resolveSelectionAttrs", true)
1449
- .put(
1450
- "suggestions",
1451
- JSONArray().put(
1452
- JSONObject()
1453
- .put("key", "u1")
1454
- .put("title", "Alice")
1455
- .put("label", "@Alice")
1456
- .put("attrs", JSONObject().put("id", "u1"))
1457
- )
1458
- )
1459
- )
1460
- .toString()
1461
- )
1462
- view.onToolbarActionForTesting = { payload ->
1463
- toolbarActionPayload = payload
1464
- }
1465
- addonPayload = null
1466
-
1467
- view.setEditable(false)
1468
- view.handleToolbarItemPressForTesting(
1469
- NativeToolbarItem(
1470
- type = ToolbarItemKind.action,
1471
- key = "custom",
1472
- label = "Custom"
1473
- )
1474
- )
1475
- view.insertMentionSuggestionForTesting(suggestion)
1476
-
1477
- assertFalse(view.hasPendingNativeActionForTesting())
1478
- assertNull(toolbarActionPayload)
1479
- assertNull(addonPayload)
1480
-
1481
- NativeEditorViewRegistry.unregister(editorId, view)
1482
- }
1483
-
1484
- @Test
1485
- fun `toolbar config change clears parked native toolbar action`() {
1486
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1487
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1488
- val editorId = 778851L
1489
- val editText = view.richTextView.editorEditText
1490
- val updateJson = renderUpdateJson("")
1491
- var toolbarActionPayload: Map<String, Any>? = null
1492
-
1493
- view.richTextView.setEditorIdWhileDetached(editorId)
1494
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1495
- editText.setSelection(0)
1496
- editText.editorId = editorId
1497
- view.setAttachedToNativeWindowForTesting(true)
1498
- view.setPendingEditorUpdateJson(updateJson)
1499
- view.setPendingEditorUpdateEditorId(editorId)
1500
- view.setPendingEditorUpdateRevision(1)
1501
- view.onToolbarActionForTesting = { payload ->
1502
- toolbarActionPayload = payload
1503
- }
1504
-
1505
- view.handleToolbarItemPressForTesting(
1506
- NativeToolbarItem(
1507
- type = ToolbarItemKind.action,
1508
- key = "custom",
1509
- label = "Custom"
1510
- )
1511
- )
1512
-
1513
- assertTrue(view.hasPendingNativeActionForTesting())
1514
-
1515
- view.setToolbarItemsJson(
1516
- JSONArray()
1517
- .put(
1518
- JSONObject()
1519
- .put("type", "action")
1520
- .put("key", "other")
1521
- .put("label", "Other")
1522
- )
1523
- .toString()
1524
- )
1525
- view.setPendingEditorUpdateJson(null)
1526
- view.setPendingEditorUpdateEditorId(editorId)
1527
- view.setPendingEditorUpdateRevision(2)
1528
- view.wakePendingPreflightWorkForTesting()
1529
-
1530
- assertFalse(view.hasPendingNativeActionForTesting())
1531
- assertNull(toolbarActionPayload)
1532
-
1533
- NativeEditorViewRegistry.unregister(editorId, view)
1534
- }
1535
-
1536
- @Test
1537
- fun `pending controlled update parks native mention selection until cleared`() {
1538
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1539
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1540
- val editorId = 77886L
1541
- val editText = view.richTextView.editorEditText
1542
- val updateJson = renderUpdateJson("Hi @ali")
1543
- val suggestion = NativeMentionSuggestion(
1544
- key = "u1",
1545
- title = "Alice",
1546
- subtitle = null,
1547
- label = "@Alice",
1548
- attrs = JSONObject().put("id", "u1")
1549
- )
1550
- var addonPayload: Map<String, Any>? = null
1551
-
1552
- view.richTextView.setEditorIdWhileDetached(editorId)
1553
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1554
- editText.setSelection(7)
1555
- editText.editorId = editorId
1556
- view.setAttachedToNativeWindowForTesting(true)
1557
- view.onAddonEventForTesting = { payload ->
1558
- addonPayload = payload
1559
- }
1560
- view.setAddonsJson(
1561
- JSONObject()
1562
- .put(
1563
- "mentions",
1564
- JSONObject()
1565
- .put("resolveSelectionAttrs", true)
1566
- .put(
1567
- "suggestions",
1568
- JSONArray().put(
1569
- JSONObject()
1570
- .put("key", "u1")
1571
- .put("title", "Alice")
1572
- .put("label", "@Alice")
1573
- .put("attrs", JSONObject().put("id", "u1"))
1574
- )
1575
- )
1576
- )
1577
- .toString()
1578
- )
1579
- addonPayload = null
1580
- view.setPendingEditorUpdateJson(updateJson)
1581
- view.setPendingEditorUpdateEditorId(editorId)
1582
- view.setPendingEditorUpdateRevision(1)
1583
-
1584
- view.insertMentionSuggestionForTesting(suggestion)
1585
-
1586
- assertTrue(view.hasPendingNativeActionForTesting())
1587
- assertNull(addonPayload)
1588
-
1589
- view.setPendingEditorUpdateJson(null)
1590
- view.setPendingEditorUpdateEditorId(editorId)
1591
- view.setPendingEditorUpdateRevision(2)
1592
- view.wakePendingPreflightWorkForTesting()
1593
-
1594
- assertFalse(view.hasPendingNativeActionForTesting())
1595
- val eventJson = JSONObject(addonPayload?.get("eventJson") as String)
1596
- assertEquals("mentionsSelectRequest", eventJson.getString("type"))
1597
- assertEquals("u1", eventJson.getString("suggestionKey"))
1598
-
1599
- NativeEditorViewRegistry.unregister(editorId, view)
1600
- }
1601
-
1602
- @Test
1603
- fun `pending native mention action is parked after retry budget and wakes later`() {
1604
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1605
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1606
- val editorId = 779865L
1607
- val editText = view.richTextView.editorEditText
1608
- val updateJson = renderUpdateJson("Hi @ali")
1609
- val suggestion = NativeMentionSuggestion(
1610
- key = "u1",
1611
- title = "Alice",
1612
- subtitle = null,
1613
- label = "@Alice",
1614
- attrs = JSONObject().put("id", "u1")
1615
- )
1616
- var addonPayload: Map<String, Any>? = null
1617
-
1618
- view.richTextView.setEditorIdWhileDetached(editorId)
1619
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1620
- editText.setSelection(7)
1621
- editText.editorId = editorId
1622
- editText.blockExternalEditorCommandPreparationForTesting = true
1623
- view.setAttachedToNativeWindowForTesting(true)
1624
- view.onAddonEventForTesting = { payload ->
1625
- addonPayload = payload
1626
- }
1627
- view.setAddonsJson(
1628
- JSONObject()
1629
- .put(
1630
- "mentions",
1631
- JSONObject()
1632
- .put("resolveSelectionAttrs", true)
1633
- .put(
1634
- "suggestions",
1635
- JSONArray().put(
1636
- JSONObject()
1637
- .put("key", "u1")
1638
- .put("title", "Alice")
1639
- .put("label", "@Alice")
1640
- .put("attrs", JSONObject().put("id", "u1"))
1641
- )
1642
- )
1643
- )
1644
- .toString()
1645
- )
1646
- addonPayload = null
1647
-
1648
- view.insertMentionSuggestionForTesting(suggestion)
1649
- repeat(4) {
1650
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16))
1651
- }
1652
-
1653
- assertTrue(view.hasPendingNativeActionForTesting())
1654
- assertTrue(view.pendingNativeActionRetryAttemptsForTesting() >= 3)
1655
-
1656
- editText.blockExternalEditorCommandPreparationForTesting = false
1657
- view.wakePendingPreflightWorkForTesting()
1658
-
1659
- assertFalse(view.hasPendingNativeActionForTesting())
1660
- val eventJson = JSONObject(addonPayload?.get("eventJson") as String)
1661
- assertEquals("mentionsSelectRequest", eventJson.getString("type"))
1662
- assertEquals("u1", eventJson.getString("suggestionKey"))
1663
-
1664
- NativeEditorViewRegistry.unregister(editorId, view)
1665
- }
1666
-
1667
- @Test
1668
- fun `destroyed editor clears parked native mention selection without emitting callback`() {
1669
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1670
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1671
- val editorId = 779862L
1672
- val editText = view.richTextView.editorEditText
1673
- val updateJson = renderUpdateJson("Hi @ali")
1674
- val suggestion = NativeMentionSuggestion(
1675
- key = "u1",
1676
- title = "Alice",
1677
- subtitle = null,
1678
- label = "@Alice",
1679
- attrs = JSONObject().put("id", "u1")
1680
- )
1681
- var addonPayload: Map<String, Any>? = null
1682
-
1683
- view.richTextView.setEditorIdWhileDetached(editorId)
1684
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1685
- editText.setSelection(7)
1686
- editText.editorId = editorId
1687
- view.setAttachedToNativeWindowForTesting(true)
1688
- view.onAddonEventForTesting = { payload ->
1689
- addonPayload = payload
1690
- }
1691
- view.setAddonsJson(
1692
- JSONObject()
1693
- .put(
1694
- "mentions",
1695
- JSONObject()
1696
- .put("resolveSelectionAttrs", true)
1697
- .put(
1698
- "suggestions",
1699
- JSONArray().put(
1700
- JSONObject()
1701
- .put("key", "u1")
1702
- .put("title", "Alice")
1703
- .put("label", "@Alice")
1704
- .put("attrs", JSONObject().put("id", "u1"))
1705
- )
1706
- )
1707
- )
1708
- .toString()
1709
- )
1710
- addonPayload = null
1711
- view.setPendingEditorUpdateJson(updateJson)
1712
- view.setPendingEditorUpdateEditorId(editorId)
1713
- view.setPendingEditorUpdateRevision(1)
1714
-
1715
- view.insertMentionSuggestionForTesting(suggestion)
1716
-
1717
- assertTrue(view.hasPendingNativeActionForTesting())
1718
-
1719
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
1720
- view.setPendingEditorUpdateJson(null)
1721
- view.setPendingEditorUpdateEditorId(editorId)
1722
- view.setPendingEditorUpdateRevision(2)
1723
- view.wakePendingPreflightWorkForTesting()
1724
-
1725
- assertFalse(view.hasPendingNativeActionForTesting())
1726
- assertNull(addonPayload)
1727
- }
1728
-
1729
- @Test
1730
- fun `addons config change clears parked native mention selection`() {
1731
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1732
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1733
- val editorId = 778861L
1734
- val editText = view.richTextView.editorEditText
1735
- val updateJson = renderUpdateJson("Hi @ali")
1736
- val suggestion = NativeMentionSuggestion(
1737
- key = "u1",
1738
- title = "Alice",
1739
- subtitle = null,
1740
- label = "@Alice",
1741
- attrs = JSONObject().put("id", "u1")
1742
- )
1743
- var addonPayload: Map<String, Any>? = null
1744
-
1745
- view.richTextView.setEditorIdWhileDetached(editorId)
1746
- editText.applyUpdateJSON(updateJson, notifyListener = false)
1747
- editText.setSelection(7)
1748
- editText.editorId = editorId
1749
- view.setAttachedToNativeWindowForTesting(true)
1750
- view.onAddonEventForTesting = { payload ->
1751
- addonPayload = payload
1752
- }
1753
- view.setAddonsJson(
1754
- JSONObject()
1755
- .put(
1756
- "mentions",
1757
- JSONObject()
1758
- .put("resolveSelectionAttrs", true)
1759
- .put(
1760
- "suggestions",
1761
- JSONArray().put(
1762
- JSONObject()
1763
- .put("key", "u1")
1764
- .put("title", "Alice")
1765
- .put("label", "@Alice")
1766
- .put("attrs", JSONObject().put("id", "u1"))
1767
- )
1768
- )
1769
- )
1770
- .toString()
1771
- )
1772
- addonPayload = null
1773
- view.setPendingEditorUpdateJson(updateJson)
1774
- view.setPendingEditorUpdateEditorId(editorId)
1775
- view.setPendingEditorUpdateRevision(1)
1776
-
1777
- view.insertMentionSuggestionForTesting(suggestion)
1778
-
1779
- assertTrue(view.hasPendingNativeActionForTesting())
1780
-
1781
- view.setAddonsJson(
1782
- JSONObject()
1783
- .put(
1784
- "mentions",
1785
- JSONObject()
1786
- .put("resolveSelectionAttrs", true)
1787
- .put("suggestions", JSONArray())
1788
- )
1789
- .toString()
1790
- )
1791
- addonPayload = null
1792
- view.setPendingEditorUpdateJson(null)
1793
- view.setPendingEditorUpdateEditorId(editorId)
1794
- view.setPendingEditorUpdateRevision(2)
1795
- view.wakePendingPreflightWorkForTesting()
1796
-
1797
- assertFalse(view.hasPendingNativeActionForTesting())
1798
- assertNull(addonPayload)
1799
-
1800
- NativeEditorViewRegistry.unregister(editorId, view)
1801
- }
1802
-
1803
- @Test
1804
- fun `view command update retry attempts advance instead of resetting for same payload`() {
1805
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1806
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1807
- val updateJson = """{"renderElements":[],"selection":{"type":"text","anchor":0,"head":0}}"""
1808
-
1809
- view.richTextView.setEditorIdWhileDetached(44556L)
1810
- view.scheduleViewCommandUpdateRetryForTesting(updateJson)
1811
-
1812
- assertEquals(updateJson, view.pendingViewCommandUpdateJsonForTesting())
1813
- assertEquals(1, view.pendingViewCommandUpdateRetryAttemptsForTesting())
1814
-
1815
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(20))
1816
-
1817
- assertEquals(updateJson, view.pendingViewCommandUpdateJsonForTesting())
1818
- assertEquals(2, view.pendingViewCommandUpdateRetryAttemptsForTesting())
1819
-
1820
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
1821
-
1822
- assertEquals(updateJson, view.pendingViewCommandUpdateJsonForTesting())
1823
- assertEquals(3, view.pendingViewCommandUpdateRetryAttemptsForTesting())
1824
- }
1825
-
1826
- @Test
1827
- fun `theme update applies when preflight is ready`() {
1828
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1829
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1830
- val themeJson = """{"backgroundColor":"#ff0000"}"""
1831
-
1832
- view.setThemeJson(themeJson)
1833
-
1834
- assertNull(view.pendingThemeJsonForTesting())
1835
- assertEquals(themeJson, view.lastThemeJsonForTesting())
1836
- }
1837
-
1838
- @Test
1839
- fun `theme update queues latest value while preflight is blocked`() {
1840
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1841
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1842
- val firstThemeJson = """{"backgroundColor":"#00ff00"}"""
1843
- val latestThemeJson = """{"backgroundColor":"#0000ff"}"""
1844
-
1845
- view.blockThemePreflightForTesting = true
1846
- view.setThemeJson(firstThemeJson)
1847
- view.setThemeJson(latestThemeJson)
1848
-
1849
- assertEquals(latestThemeJson, view.pendingThemeJsonForTesting())
1850
- assertNull(view.lastThemeJsonForTesting())
1851
-
1852
- view.blockThemePreflightForTesting = false
1853
- view.applyPendingThemeForTesting()
1854
-
1855
- assertNull(view.pendingThemeJsonForTesting())
1856
- assertEquals(latestThemeJson, view.lastThemeJsonForTesting())
1857
- }
1858
-
1859
- @Test
1860
- fun `scheduled theme retry applies pending theme after preflight unblocks`() {
1861
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1862
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1863
- val themeJson = """{"backgroundColor":"#112233"}"""
1864
-
1865
- view.blockThemePreflightForTesting = true
1866
- view.setThemeJson(themeJson)
1867
- assertEquals(themeJson, view.pendingThemeJsonForTesting())
1868
-
1869
- view.blockThemePreflightForTesting = false
1870
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16))
1871
-
1872
- assertNull(view.pendingThemeJsonForTesting())
1873
- assertEquals(themeJson, view.lastThemeJsonForTesting())
1874
- }
1875
-
1876
- @Test
1877
- fun `theme retry is bounded while preflight remains blocked`() {
1878
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1879
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1880
- val themeJson = """{"backgroundColor":"#112233"}"""
1881
-
1882
- view.blockThemePreflightForTesting = true
1883
- view.setThemeJson(themeJson)
1884
-
1885
- repeat(10) {
1886
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
1887
- }
1888
-
1889
- assertEquals(themeJson, view.pendingThemeJsonForTesting())
1890
- assertTrue(view.pendingThemeRetryAttemptsForTesting() <= 5)
1891
- assertNull(view.lastThemeJsonForTesting())
1892
- }
1893
-
1894
- @Test
1895
- fun `theme update wakes after retry budget is exhausted`() {
1896
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1897
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1898
- val themeJson = """{"backgroundColor":"#445566"}"""
1899
-
1900
- view.blockThemePreflightForTesting = true
1901
- view.setThemeJson(themeJson)
1902
-
1903
- repeat(10) {
1904
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
1905
- }
1906
-
1907
- assertEquals(themeJson, view.pendingThemeJsonForTesting())
1908
- assertTrue(view.pendingThemeRetryAttemptsForTesting() <= 5)
1909
- assertNull(view.lastThemeJsonForTesting())
1910
-
1911
- view.blockThemePreflightForTesting = false
1912
- view.wakePendingPreflightWorkForTesting()
1913
-
1914
- assertNull(view.pendingThemeJsonForTesting())
1915
- assertEquals(themeJson, view.lastThemeJsonForTesting())
1916
- }
1917
-
1918
- @Test
1919
- fun `theme update can clear an applied theme with null json`() {
1920
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1921
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1922
- val themeJson = """{"backgroundColor":"#ff0000"}"""
1923
-
1924
- view.setThemeJson(themeJson)
1925
- view.setThemeJson(null)
1926
-
1927
- assertNull(view.pendingThemeJsonForTesting())
1928
- assertNull(view.lastThemeJsonForTesting())
1929
- }
1930
-
1931
- @Test
1932
- fun `blur retries preflight until it unblocks`() {
1933
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1934
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1935
- val editText = view.richTextView.editorEditText
1936
-
1937
- editText.blockExternalEditorUpdatePreparationForTesting = true
1938
-
1939
- view.performBlurForTesting()
1940
-
1941
- assertEquals(1, view.pendingBlurRetryAttemptsForTesting())
1942
-
1943
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(20))
1944
-
1945
- assertEquals(2, view.pendingBlurRetryAttemptsForTesting())
1946
-
1947
- editText.blockExternalEditorUpdatePreparationForTesting = false
1948
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
1949
-
1950
- assertEquals(0, view.pendingBlurRetryAttemptsForTesting())
1951
- }
1952
-
1953
- @Test
1954
- fun `blur retry clears when editor is destroyed before preflight unblocks`() {
1955
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1956
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1957
- val editorId = 779900L
1958
- val editText = view.richTextView.editorEditText
1959
-
1960
- view.richTextView.setEditorIdWhileDetached(editorId)
1961
- editText.editorId = editorId
1962
- editText.blockExternalEditorUpdatePreparationForTesting = true
1963
-
1964
- view.performBlurForTesting()
1965
-
1966
- assertEquals(1, view.pendingBlurRetryAttemptsForTesting())
1967
-
1968
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
1969
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(20))
1970
-
1971
- assertEquals(0, view.pendingBlurRetryAttemptsForTesting())
1972
- assertEquals(0L, view.richTextView.editorId)
1973
- assertEquals(0L, editText.editorId)
1974
- }
1975
-
1976
- @Test
1977
- fun `destroyed editor cancels pending outside tap keyboard dismiss and preflight wake`() {
1978
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
1979
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
1980
- val editorId = 779902L
1981
- val editText = view.richTextView.editorEditText
1982
-
1983
- view.richTextView.setEditorIdWhileDetached(editorId)
1984
- editText.editorId = editorId
1985
- view.setAttachedToNativeWindowForTesting(true)
1986
- NativeEditorViewRegistry.register(editorId, view)
1987
-
1988
- view.scheduleOutsideTapBlurFromWindowDispatcher()
1989
- view.performBlurForTesting(deferKeyboardDismiss = true)
1990
- view.schedulePendingPreflightWakeForTesting()
1991
-
1992
- assertTrue(view.hasPendingOutsideTapBlurForTesting())
1993
- assertTrue(view.hasPendingKeyboardDismissForTesting())
1994
- assertTrue(view.hasPendingPreflightWakeForTesting())
1995
-
1996
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
1997
-
1998
- assertFalse(view.hasPendingOutsideTapBlurForTesting())
1999
- assertFalse(view.hasPendingKeyboardDismissForTesting())
2000
- assertFalse(view.hasPendingPreflightWakeForTesting())
2001
- assertFalse(view.isKeyboardToolbarAttachedForTesting())
2002
- assertEquals(0L, view.richTextView.editorId)
2003
- assertEquals(0L, editText.editorId)
2004
- }
2005
-
2006
- @Test
2007
- fun `destroyed editor cancels pending toolbar refocus`() {
2008
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2009
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2010
- val editorId = 779903L
2011
- val editText = view.richTextView.editorEditText
2012
-
2013
- view.richTextView.setEditorIdWhileDetached(editorId)
2014
- editText.editorId = editorId
2015
- view.setAttachedToNativeWindowForTesting(true)
2016
- NativeEditorViewRegistry.register(editorId, view)
2017
-
2018
- view.scheduleToolbarRefocusForTesting()
2019
-
2020
- assertTrue(view.hasPendingToolbarRefocusForTesting())
2021
-
2022
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
2023
- shadowOf(Looper.getMainLooper()).idle()
2024
-
2025
- assertFalse(view.hasPendingToolbarRefocusForTesting())
2026
- assertFalse(editText.hasFocus())
2027
- assertEquals(0L, view.richTextView.editorId)
2028
- assertEquals(0L, editText.editorId)
2029
- }
2030
-
2031
- @Test
2032
- fun `outside tap observer is shared per window and removed after last view`() {
2033
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2034
- val host = activity.findViewById<FrameLayout>(android.R.id.content)
2035
- val firstExpoContext = testExpoContext(activity)
2036
- val secondExpoContext = testExpoContext(activity)
2037
- val firstView = NativeEditorExpoView(firstExpoContext.context, firstExpoContext.appContext)
2038
- val secondView = NativeEditorExpoView(secondExpoContext.context, secondExpoContext.appContext)
2039
- val originalChildCount = host.childCount
2040
-
2041
- firstView.installOutsideTapBlurHandlerForTesting()
2042
- val observer = host.getChildAt(host.childCount - 1)
2043
- assertEquals(originalChildCount + 1, host.childCount)
2044
-
2045
- secondView.installOutsideTapBlurHandlerForTesting()
2046
-
2047
- assertEquals(originalChildCount + 1, host.childCount)
2048
- assertSame(observer, host.getChildAt(host.childCount - 1))
2049
-
2050
- firstView.uninstallOutsideTapBlurHandlerForTesting()
2051
-
2052
- assertEquals(originalChildCount + 1, host.childCount)
2053
- assertSame(observer, host.getChildAt(host.childCount - 1))
2054
-
2055
- secondView.uninstallOutsideTapBlurHandlerForTesting()
2056
- shadowOf(Looper.getMainLooper()).idle()
2057
-
2058
- assertEquals(originalChildCount, host.childCount)
2059
- }
2060
-
2061
- @Test
2062
- fun `outside tap observer does not consume touches and confirms tap before scheduling blur`() {
2063
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2064
- val host = activity.findViewById<FrameLayout>(android.R.id.content)
2065
- val expoContext = testExpoContext(activity)
2066
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2067
- val trace = mutableListOf<String>()
2068
- host.addView(view, FrameLayout.LayoutParams(200, 200))
2069
- host.layout(0, 0, 1000, 1000)
2070
- view.layout(0, 0, 200, 200)
2071
- view.richTextView.layout(0, 0, 200, 200)
2072
- view.richTextView.editorEditText.layout(0, 0, 200, 200)
2073
- view.setAttachedToNativeWindowForTesting(true)
2074
- view.setEditorFocusedForOutsideTapDecisionForTesting(true)
2075
- view.onAddonEventForTesting = {}
2076
- view.onFocusChangeForTesting = {}
2077
- view.onOutsideTapTraceForTesting = { event -> trace.add(event) }
2078
-
2079
- view.installOutsideTapBlurHandlerForTesting()
2080
- val observer = host.getChildAt(host.childCount - 1)
2081
-
2082
- val event = MotionEvent.obtain(100L, 100L, MotionEvent.ACTION_DOWN, 9999f, 9999f, 0)
2083
- val handled = observer.dispatchTouchEvent(event)
2084
- event.recycle()
2085
-
2086
- assertFalse(handled)
2087
- assertFalse(view.hasPendingOutsideTapBlurForTesting())
2088
-
2089
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(151))
2090
-
2091
- assertTrue(trace.joinToString(separator = "\n"), view.hasPendingOutsideTapBlurForTesting())
2092
-
2093
- view.cancelOutsideTapBlurFromWindowDispatcher()
2094
- view.uninstallOutsideTapBlurHandlerForTesting()
2095
- }
2096
-
2097
- @Test
2098
- fun `outside tap observer cancels outside blur candidate when gesture moves like scroll`() {
2099
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2100
- val host = activity.findViewById<FrameLayout>(android.R.id.content)
2101
- val expoContext = testExpoContext(activity)
2102
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2103
- host.addView(view, FrameLayout.LayoutParams(200, 200))
2104
- host.layout(0, 0, 1000, 1000)
2105
- view.layout(0, 0, 200, 200)
2106
- view.richTextView.layout(0, 0, 200, 200)
2107
- view.richTextView.editorEditText.layout(0, 0, 200, 200)
2108
- view.setAttachedToNativeWindowForTesting(true)
2109
- view.setEditorFocusedForOutsideTapDecisionForTesting(true)
2110
- view.onAddonEventForTesting = {}
2111
- view.onFocusChangeForTesting = {}
2112
-
2113
- view.installOutsideTapBlurHandlerForTesting()
2114
- val observer = host.getChildAt(host.childCount - 1)
2115
-
2116
- val down = MotionEvent.obtain(100L, 100L, MotionEvent.ACTION_DOWN, 9999f, 9999f, 0)
2117
- val move = MotionEvent.obtain(100L, 116L, MotionEvent.ACTION_MOVE, 9999f, 10099f, 0)
2118
- observer.dispatchTouchEvent(down)
2119
- observer.dispatchTouchEvent(move)
2120
- down.recycle()
2121
- move.recycle()
2122
-
2123
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(151))
2124
-
2125
- assertFalse(view.hasPendingOutsideTapBlurForTesting())
2126
-
2127
- view.uninstallOutsideTapBlurHandlerForTesting()
2128
- }
2129
-
2130
- @Test
2131
- fun `outside tap handler reinstall does not duplicate observer for same view`() {
2132
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2133
- val host = activity.findViewById<FrameLayout>(android.R.id.content)
2134
- val expoContext = testExpoContext(activity)
2135
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2136
-
2137
- host.addView(view)
2138
- view.setAttachedToNativeWindowForTesting(true)
2139
- view.setEditorFocusedForOutsideTapDecisionForTesting(true)
2140
- view.onAddonEventForTesting = {}
2141
- view.onFocusChangeForTesting = {}
2142
-
2143
- view.installOutsideTapBlurHandlerForTesting()
2144
- assertTrue(view.isOutsideTapBlurHandlerInstalledForTesting())
2145
- val childCount = host.childCount
2146
- val observer = host.getChildAt(host.childCount - 1)
2147
-
2148
- view.installOutsideTapBlurHandlerForTesting()
2149
- assertTrue(view.isOutsideTapBlurHandlerInstalledForTesting())
2150
- assertEquals(childCount, host.childCount)
2151
- assertSame(observer, host.getChildAt(host.childCount - 1))
2152
-
2153
- val event = MotionEvent.obtain(100L, 100L, MotionEvent.ACTION_DOWN, 9999f, 9999f, 0)
2154
- assertEquals(
2155
- NativeEditorOutsideTapDecision.OUTSIDE_EDITOR,
2156
- view.prepareOutsideTapDecisionForWindowEvent(event)
2157
- )
2158
- event.recycle()
2159
-
2160
- view.cancelOutsideTapBlurFromWindowDispatcher()
2161
- view.uninstallOutsideTapBlurHandlerForTesting()
2162
- }
2163
-
2164
- @Test
2165
- fun `detach clears keyboard toolbar viewport inset`() {
2166
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2167
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2168
-
2169
- view.richTextView.setViewportBottomInsetPx(42)
2170
- view.setCurrentImeBottomForTesting(120)
2171
-
2172
- assertEquals(42, view.richTextView.viewportBottomInsetPxForTesting())
2173
- assertEquals(120, view.currentImeBottomForTesting())
2174
-
2175
- view.handleDetachedFromWindowForTesting()
2176
-
2177
- assertEquals(0, view.richTextView.viewportBottomInsetPxForTesting())
2178
- assertEquals(0, view.currentImeBottomForTesting())
2179
- }
2180
-
2181
- @Test
2182
- fun `toolbar theme refreshes fixed viewport inset`() {
2183
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2184
- val host = FrameLayout(activity)
2185
- activity.setContentView(host)
2186
- val expoContext = testExpoContext(activity)
2187
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2188
- val editorId = 778895L
2189
- val editText = view.richTextView.editorEditText
2190
-
2191
- host.addView(view, FrameLayout.LayoutParams(360, 480))
2192
- view.measure(
2193
- android.view.View.MeasureSpec.makeMeasureSpec(360, android.view.View.MeasureSpec.EXACTLY),
2194
- android.view.View.MeasureSpec.makeMeasureSpec(480, android.view.View.MeasureSpec.EXACTLY)
2195
- )
2196
- view.layout(0, 0, 360, 480)
2197
- view.richTextView.setEditorIdWhileDetached(editorId)
2198
- editText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
2199
- editText.editorId = editorId
2200
- view.setAttachedToNativeWindowForTesting(true)
2201
- view.setCurrentImeBottomForTesting(160)
2202
- view.onAddonEventForTesting = {}
2203
- view.onFocusChangeForTesting = {}
2204
- assertTrue(editText.requestFocus())
2205
- shadowOf(Looper.getMainLooper()).idle()
2206
- editText.editorId = 0L
2207
- view.richTextView.setViewportBottomInsetPx(1)
2208
-
2209
- view.setThemeJson("""{"toolbar":{"appearance":"native"}}""")
2210
- shadowOf(Looper.getMainLooper()).idle()
2211
-
2212
- assertTrue(view.richTextView.viewportBottomInsetPxForTesting() > 1)
2213
-
2214
- NativeEditorViewRegistry.unregister(editorId, view)
2215
- }
2216
-
2217
- @Test
2218
- fun `auto grow content height re-emits when editor id changes`() {
2219
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2220
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2221
- val editText = view.richTextView.editorEditText
2222
- val events = mutableListOf<Map<String, Any>>()
2223
-
2224
- view.onContentHeightChangeForTesting = { event ->
2225
- events.add(event)
2226
- }
2227
- view.setHeightBehavior("autoGrow")
2228
- editText.applyUpdateJSON(
2229
- renderUpdateJson("Line one\nLine two\nLine three"),
2230
- notifyListener = false
2231
- )
2232
-
2233
- val widthSpec = android.view.View.MeasureSpec.makeMeasureSpec(
2234
- 360,
2235
- android.view.View.MeasureSpec.EXACTLY
2236
- )
2237
- val heightSpec = android.view.View.MeasureSpec.makeMeasureSpec(
2238
- 0,
2239
- android.view.View.MeasureSpec.UNSPECIFIED
2240
- )
2241
- view.measure(widthSpec, heightSpec)
2242
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
2243
-
2244
- assertTrue(events.isNotEmpty())
2245
- val initialEvent = events.last()
2246
- val contentHeight = initialEvent["contentHeight"] as Int
2247
- assertEquals(0L, initialEvent["editorId"])
2248
-
2249
- events.clear()
2250
- val editorId = 779902L
2251
-
2252
- view.setEditorId(editorId)
2253
- view.measure(widthSpec, heightSpec)
2254
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
2255
-
2256
- assertEquals(1, events.size)
2257
- assertEquals(contentHeight, events.single()["contentHeight"])
2258
- assertEquals(editorId, events.single()["editorId"])
2259
-
2260
- NativeEditorViewRegistry.unregister(editorId, view)
2261
- }
2262
-
2263
- @Test
2264
- fun `detach preflight flushes pending composition before unregistering`() {
2265
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2266
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2267
- val editText = view.richTextView.editorEditText
2268
-
2269
- view.richTextView.setEditorIdWhileDetached(77889L)
2270
- editText.setSelection(0)
2271
- editText.editorId = 77889L
2272
-
2273
- var insertedText: String? = null
2274
- editText.onInsertTextInRustForTesting = { text, _ ->
2275
- insertedText = text
2276
- editText.applyUpdateJSON(renderUpdateJson(text), notifyListener = false)
2277
- }
2278
-
2279
- val inputConnection = editText.onCreateInputConnection(android.view.inputmethod.EditorInfo())
2280
- assertNotNull(inputConnection)
2281
- assertTrue(inputConnection!!.setComposingText("abc", 1))
2282
-
2283
- view.handleDetachedFromWindowForTesting()
2284
-
2285
- assertEquals("abc", insertedText)
2286
-
2287
- NativeEditorViewRegistry.unregister(77889L, view)
2288
- }
2289
-
2290
- @Test
2291
- fun `detach retry clears when editor is destroyed before preflight unblocks`() {
2292
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2293
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2294
- val editorId = 779901L
2295
- val editText = view.richTextView.editorEditText
2296
-
2297
- view.richTextView.setEditorIdWhileDetached(editorId)
2298
- editText.editorId = editorId
2299
- editText.blockExternalEditorUpdatePreparationForTesting = true
2300
-
2301
- view.handleDetachedFromWindowForTesting()
2302
-
2303
- assertEquals(1, view.pendingDetachPreflightRetryAttemptsForTesting())
2304
-
2305
- NativeEditorViewRegistry.invalidateDestroyedEditor(editorId)
2306
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(20))
2307
-
2308
- assertEquals(0, view.pendingDetachPreflightRetryAttemptsForTesting())
2309
- assertEquals(0L, view.richTextView.editorId)
2310
- assertEquals(0L, editText.editorId)
2311
- }
2312
-
2313
- @Test
2314
- fun `child detach preflight flushes pending composition before editor unbind`() {
2315
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2316
- val parent = FrameLayout(activity)
2317
- activity.setContentView(parent)
2318
- val expoContext = testExpoContext(activity)
2319
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2320
- val editText = view.richTextView.editorEditText
2321
- val editorId = 778891L
2322
-
2323
- parent.addView(view)
2324
- view.richTextView.setEditorIdWhileDetached(editorId)
2325
- editText.applyUpdateJSON(renderUpdateJson(""), notifyListener = false)
2326
- editText.setSelection(0)
2327
- editText.editorId = editorId
2328
-
2329
- var insertedText: String? = null
2330
- editText.onInsertTextInRustForTesting = { text, _ ->
2331
- insertedText = text
2332
- editText.applyUpdateJSON(renderUpdateJson(text), notifyListener = false)
2333
- }
2334
-
2335
- val inputConnection = editText.onCreateInputConnection(android.view.inputmethod.EditorInfo())
2336
- assertNotNull(inputConnection)
2337
- assertTrue(inputConnection!!.setComposingText("abc", 1))
2338
-
2339
- parent.removeView(view)
2340
-
2341
- assertEquals("abc", insertedText)
2342
- assertEquals(0L, editText.editorId)
2343
-
2344
- NativeEditorViewRegistry.unregister(editorId, view)
2345
- }
2346
-
2347
- @Test
2348
- fun `pending native toolbar action is parked after retry budget and wakes later`() {
2349
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2350
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2351
- val editText = view.richTextView.editorEditText
2352
-
2353
- view.richTextView.setEditorIdWhileDetached(88990L)
2354
- editText.setSelection(0)
2355
- editText.editorId = 88990L
2356
- editText.blockExternalEditorCommandPreparationForTesting = true
2357
- var toolbarActionPayload: Map<String, Any>? = null
2358
- view.onToolbarActionForTesting = { payload ->
2359
- toolbarActionPayload = payload
2360
- }
2361
-
2362
- val action = NativeToolbarItem(
2363
- type = ToolbarItemKind.action,
2364
- key = "custom",
2365
- label = "Custom"
2366
- )
2367
-
2368
- view.handleToolbarItemPressForTesting(action)
2369
- repeat(4) {
2370
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16))
2371
- }
2372
-
2373
- assertTrue(view.hasPendingNativeActionForTesting())
2374
- assertTrue(view.pendingNativeActionRetryAttemptsForTesting() >= 3)
2375
-
2376
- editText.blockExternalEditorCommandPreparationForTesting = false
2377
- view.wakePendingPreflightWorkForTesting()
2378
-
2379
- assertFalse(view.hasPendingNativeActionForTesting())
2380
- assertEquals("custom", toolbarActionPayload?.get("key"))
2381
- assertEquals(88990L, toolbarActionPayload?.get("editorId"))
2382
-
2383
- NativeEditorViewRegistry.unregister(88990L, view)
2384
- }
2385
-
2386
- @Test
2387
- fun `view command update wakes after retry budget is exhausted`() {
2388
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2389
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2390
- val editorId = 88991L
2391
- val editText = view.richTextView.editorEditText
2392
- val updateJson = renderUpdateJson("next")
2393
-
2394
- view.richTextView.setEditorIdWhileDetached(editorId)
2395
- editText.applyUpdateJSON(renderUpdateJson("before"), notifyListener = false)
2396
- editText.setSelection(0)
2397
- editText.editorId = editorId
2398
- view.setAttachedToNativeWindowForTesting(true)
2399
- view.blockEditorUpdatePreflightForTesting = true
2400
-
2401
- assertFalse(view.applyEditorUpdate(updateJson))
2402
-
2403
- repeat(10) {
2404
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100))
2405
- }
2406
-
2407
- assertEquals(updateJson, view.pendingViewCommandUpdateJsonForTesting())
2408
- assertTrue(view.pendingViewCommandUpdateRetryAttemptsForTesting() <= 5)
2409
- assertEquals("before", editText.text?.toString())
2410
-
2411
- view.blockEditorUpdatePreflightForTesting = false
2412
- view.wakePendingPreflightWorkForTesting()
2413
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16))
2414
-
2415
- assertNull(view.pendingViewCommandUpdateJsonForTesting())
2416
- assertEquals("next", editText.text?.toString())
2417
- }
2418
-
2419
- @Test
2420
- fun `off main view command update is ignored after editor rebind`() {
2421
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2422
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2423
- val firstEditorId = 99101L
2424
- val secondEditorId = 99102L
2425
- val editText = view.richTextView.editorEditText
2426
- val updateJson = renderUpdateJson("stale")
2427
-
2428
- view.richTextView.setEditorIdWhileDetached(firstEditorId)
2429
- editText.applyUpdateJSON(renderUpdateJson("first"), notifyListener = false)
2430
- editText.setSelection(0)
2431
- editText.editorId = firstEditorId
2432
- view.setAttachedToNativeWindowForTesting(true)
2433
-
2434
- val posted = CountDownLatch(1)
2435
- Thread {
2436
- view.applyEditorUpdate(updateJson)
2437
- posted.countDown()
2438
- }.start()
2439
- assertTrue(posted.await(2, java.util.concurrent.TimeUnit.SECONDS))
2440
-
2441
- view.richTextView.setEditorIdWhileDetached(secondEditorId)
2442
- editText.applyUpdateJSON(renderUpdateJson("second"), notifyListener = false)
2443
- editText.setSelection(0)
2444
- editText.editorId = secondEditorId
2445
-
2446
- shadowOf(Looper.getMainLooper()).idle()
2447
-
2448
- assertEquals("second", editText.text?.toString())
2449
- assertNull(view.pendingViewCommandUpdateJsonForTesting())
2450
- }
2451
-
2452
- @Test
2453
- fun `interrupted running off main preflight returns completed result`() {
2454
- val expoContext = testExpoContext(RuntimeEnvironment.getApplication())
2455
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2456
- val editorId = 99103L
2457
- val started = CountDownLatch(1)
2458
- val release = CountDownLatch(1)
2459
- val result = AtomicReference<String>()
2460
-
2461
- view.richTextView.setEditorIdWhileDetached(editorId)
2462
- view.richTextView.editorEditText.applyUpdateJSON(renderUpdateJson("ready"), notifyListener = false)
2463
- view.richTextView.editorEditText.setSelection(0)
2464
- view.richTextView.editorEditText.editorId = editorId
2465
- view.setAttachedToNativeWindowForTesting(true)
2466
- view.onBeforePrepareForEditorCommandForTesting = {
2467
- started.countDown()
2468
- assertTrue(release.await(2, java.util.concurrent.TimeUnit.SECONDS))
2469
- }
2470
- NativeEditorViewRegistry.register(editorId, view)
2471
-
2472
- val worker = Thread {
2473
- result.set(NativeEditorViewRegistry.prepareForCommandJSON(editorId))
2474
- }
2475
- val interrupter = Thread {
2476
- assertTrue(started.await(2, java.util.concurrent.TimeUnit.SECONDS))
2477
- worker.interrupt()
2478
- release.countDown()
2479
- }
2480
-
2481
- worker.start()
2482
- interrupter.start()
2483
- shadowOf(Looper.getMainLooper()).idle()
2484
- worker.join(2000)
2485
- interrupter.join(2000)
2486
-
2487
- val preparation = JSONObject(result.get())
2488
- assertTrue(preparation.getBoolean("ready"))
2489
-
2490
- NativeEditorViewRegistry.unregister(editorId, view)
2491
- }
2492
-
2493
- private fun renderUpdateJson(text: String): String =
2494
- JSONObject()
2495
- .put(
2496
- "renderBlocks",
2497
- JSONArray().put(
2498
- JSONArray()
2499
- .put(
2500
- JSONObject()
2501
- .put("type", "blockStart")
2502
- .put("nodeType", "paragraph")
2503
- .put("depth", 0)
2504
- )
2505
- .put(
2506
- JSONObject()
2507
- .put("type", "textRun")
2508
- .put("text", text)
2509
- .put("marks", JSONArray())
2510
- )
2511
- .put(JSONObject().put("type", "blockEnd"))
2512
- )
2513
- )
2514
- .toString()
2515
-
2516
- private data class TestExpoContext(
2517
- val context: Context,
2518
- val appContext: AppContext
2519
- )
2520
-
2521
- private fun attachedNativeEditorView(): NativeEditorExpoView {
2522
- val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
2523
- val host = FrameLayout(activity)
2524
- activity.setContentView(host)
2525
- val expoContext = testExpoContext(activity)
2526
- val view = NativeEditorExpoView(expoContext.context, expoContext.appContext)
2527
- val editorId = 779904L
2528
- val editText = view.richTextView.editorEditText
2529
-
2530
- view.onFocusChangeForTesting = {}
2531
- view.onAddonEventForTesting = {}
2532
- host.addView(view, FrameLayout.LayoutParams(200, 200))
2533
- val widthSpec = android.view.View.MeasureSpec.makeMeasureSpec(
2534
- 200,
2535
- android.view.View.MeasureSpec.EXACTLY
2536
- )
2537
- val heightSpec = android.view.View.MeasureSpec.makeMeasureSpec(
2538
- 200,
2539
- android.view.View.MeasureSpec.EXACTLY
2540
- )
2541
- view.measure(widthSpec, heightSpec)
2542
- view.layout(0, 0, 200, 200)
2543
- view.richTextView.setEditorIdWhileDetached(editorId)
2544
- editText.applyUpdateJSON(renderUpdateJson("ready"), notifyListener = false)
2545
- editText.setSelection(0)
2546
- editText.editorId = editorId
2547
- view.setAttachedToNativeWindowForTesting(true)
2548
- view.setEditorFocusedForOutsideTapDecisionForTesting(true)
2549
- return view
2550
- }
2551
-
2552
- private fun testExpoContext(
2553
- context: Context,
2554
- currentActivity: Activity? = null
2555
- ): TestExpoContext {
2556
- val resolvedCurrentActivity = currentActivity ?: context as? Activity
2557
- val reactContext = Class
2558
- .forName("com.facebook.react.bridge.BridgeReactContext")
2559
- .getConstructor(Context::class.java)
2560
- .newInstance(context) as Context
2561
-
2562
- if (resolvedCurrentActivity != null) {
2563
- reactContext.javaClass
2564
- .getMethod("onHostResume", Activity::class.java)
2565
- .invoke(reactContext, resolvedCurrentActivity)
2566
- }
2567
-
2568
- val modulesProvider = object : ModulesProvider {
2569
- override fun getModulesList(): List<Class<out Module>> = emptyList()
2570
- }
2571
- val constructor = AppContext::class.java.constructors.first { constructor ->
2572
- constructor.parameterTypes.size == 3
2573
- }
2574
- val appContext = constructor.newInstance(
2575
- modulesProvider,
2576
- ModuleRegistry(emptyList(), emptyList()),
2577
- WeakReference(reactContext)
2578
- ) as AppContext
2579
- return TestExpoContext(reactContext, appContext)
2580
- }
2581
- }