1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.foundation.text.input
18 
19 import android.content.ClipDescription
20 import android.net.Uri
21 import android.os.Bundle
22 import android.view.inputmethod.EditorInfo
23 import android.view.inputmethod.InputConnection
24 import android.view.inputmethod.InputContentInfo
25 import androidx.compose.foundation.ExperimentalFoundationApi
26 import androidx.compose.foundation.content.TransferableContent
27 import androidx.compose.foundation.content.assertClipData
28 import androidx.compose.foundation.content.consume
29 import androidx.compose.foundation.content.contentReceiver
30 import androidx.compose.foundation.content.createClipData
31 import androidx.compose.foundation.text.BasicTextField
32 import androidx.compose.foundation.text.input.internal.selection.FakeClipboard
33 import androidx.compose.foundation.text.selection.FakeTextToolbar
34 import androidx.compose.runtime.CompositionLocalProvider
35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.platform.LocalClipboard
37 import androidx.compose.ui.platform.LocalTextToolbar
38 import androidx.compose.ui.platform.testTag
39 import androidx.compose.ui.platform.toClipEntry
40 import androidx.compose.ui.semantics.SemanticsActions
41 import androidx.compose.ui.test.hasSetTextAction
42 import androidx.compose.ui.test.junit4.createComposeRule
43 import androidx.compose.ui.test.onNodeWithTag
44 import androidx.compose.ui.test.performSemanticsAction
45 import androidx.compose.ui.test.requestFocus
46 import androidx.core.view.inputmethod.EditorInfoCompat
47 import androidx.core.view.inputmethod.InputConnectionCompat
48 import androidx.core.view.inputmethod.InputContentInfoCompat
49 import androidx.test.ext.junit.runners.AndroidJUnit4
50 import androidx.test.filters.MediumTest
51 import androidx.test.filters.SdkSuppress
52 import com.google.common.truth.Truth.assertThat
53 import kotlin.test.assertFalse
54 import kotlin.test.assertTrue
55 import kotlinx.coroutines.test.runTest
56 import org.junit.Rule
57 import org.junit.Test
58 import org.junit.runner.RunWith
59 
60 /** Tests InputConnection#commitContent calls from BasicTextField to receiveContent modifier. */
61 @MediumTest
62 @RunWith(AndroidJUnit4::class)
63 @OptIn(ExperimentalFoundationApi::class)
64 class TextFieldReceiveContentTest {
65 
66     @get:Rule val rule = createComposeRule()
67 
68     private val inputMethodInterceptor = InputMethodInterceptor(rule)
69 
70     private val tag = "BasicTextField"
71 
72     @SdkSuppress(minSdkVersion = 25)
73     @Test
74     fun commitContentReturnsFalse_whenNoReceiveContentConfigured() {
75         inputMethodInterceptor.setContent {
76             BasicTextField(state = rememberTextFieldState(), modifier = Modifier.testTag(tag))
77         }
78         rule.onNodeWithTag(tag).requestFocus()
79         inputMethodInterceptor.withInputConnection {
80             assertFalse(
81                 commitContent(createInputContentInfo().unwrap() as InputContentInfo, 0, null)
82             )
83         }
84     }
85 
86     @SdkSuppress(maxSdkVersion = 24)
87     @Test
88     fun preformPrivateCommandReturnsFalse_whenNoReceiveContentConfigured() {
89         inputMethodInterceptor.setContent {
90             BasicTextField(state = rememberTextFieldState(), modifier = Modifier.testTag(tag))
91         }
92         rule.onNodeWithTag(tag).requestFocus()
93         inputMethodInterceptor.onIdle { editorInfo, inputConnection ->
94             // Although we are testing `performPrivateCommand` that should return true by default
95             // in the existence of no configuration, semantically the caller is still calling
96             // commitContent which should return false by default.
97             assertFalse(
98                 InputConnectionCompat.commitContent(
99                     inputConnection,
100                     editorInfo,
101                     InputContentInfoCompat(DEFAULT_CONTENT_URI, DEFAULT_CLIP_DESCRIPTION, null),
102                     0,
103                     null
104                 )
105             )
106         }
107     }
108 
109     @Test
110     fun singleReceiveContent_configuresEditorInfo() {
111         inputMethodInterceptor.setContent {
112             BasicTextField(
113                 state = rememberTextFieldState(),
114                 modifier = Modifier.testTag(tag).contentReceiver { null }
115             )
116         }
117         rule.onNodeWithTag(tag).requestFocus()
118         inputMethodInterceptor.withEditorInfo {
119             val contentMimeTypes = EditorInfoCompat.getContentMimeTypes(this)
120             assertThat(contentMimeTypes).asList().containsAtLeastElementsIn(arrayOf("*/*"))
121         }
122     }
123 
124     @Test
125     fun singleReceiveContent_isCalledAfterCommitContent() {
126         var transferableContent: TransferableContent? = null
127         inputMethodInterceptor.setContent {
128             BasicTextField(
129                 state = rememberTextFieldState(),
130                 modifier =
131                     Modifier.testTag(tag).contentReceiver {
132                         transferableContent = it
133                         null
134                     }
135             )
136         }
137         rule.onNodeWithTag(tag).requestFocus()
138 
139         val linkUri = Uri.parse("https://example.com")
140         val bundle = Bundle().apply { putString("key", "value") }
141         inputMethodInterceptor.onIdle { editorInfo, inputConnection ->
142             InputConnectionCompat.commitContent(
143                 inputConnection,
144                 editorInfo,
145                 createInputContentInfo(linkUri = linkUri),
146                 0,
147                 bundle
148             )
149         }
150 
151         rule.runOnIdle {
152             assertThat(transferableContent).isNotNull()
153             assertThat(transferableContent?.source).isEqualTo(TransferableContent.Source.Keyboard)
154             assertThat(transferableContent?.clipMetadata?.clipDescription)
155                 .isEqualTo(DEFAULT_CLIP_DESCRIPTION)
156 
157             assertThat(transferableContent?.clipEntry?.clipData?.itemCount).isEqualTo(1)
158             assertThat(transferableContent?.clipEntry?.clipData?.getItemAt(0)?.uri)
159                 .isEqualTo(DEFAULT_CONTENT_URI)
160 
161             assertThat(transferableContent?.platformTransferableContent?.linkUri).isEqualTo(linkUri)
162             assertThat(transferableContent?.platformTransferableContent?.extras).isEqualTo(bundle)
163         }
164     }
165 
166     @SdkSuppress(minSdkVersion = 25) // Permissions are acquired only on SDK levels 25 or higher.
167     @Test
168     fun singleReceiveContent_permissionIsRequested() {
169         var transferableContent: TransferableContent? = null
170         inputMethodInterceptor.setContent {
171             BasicTextField(
172                 state = rememberTextFieldState(),
173                 modifier =
174                     Modifier.testTag(tag).contentReceiver {
175                         transferableContent = it
176                         null
177                     }
178             )
179         }
180         rule.onNodeWithTag(tag).requestFocus()
181 
182         val inputContentInfo: InputContentInfoCompat = createInputContentInfo()
183 
184         inputMethodInterceptor.onIdle { editorInfo, inputConnection ->
185             InputConnectionCompat.commitContent(
186                 inputConnection,
187                 editorInfo,
188                 inputContentInfo,
189                 InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION,
190                 null
191             )
192         }
193 
194         rule.runOnIdle {
195             assertThat(transferableContent).isNotNull()
196             assertTrue(
197                 transferableContent
198                     ?.platformTransferableContent
199                     ?.extras
200                     ?.containsKey("EXTRA_INPUT_CONTENT_INFO") ?: false
201             )
202         }
203     }
204 
205     @Test
206     fun multiReceiveContent_delegatesRemainingItems_toParent() {
207         var childTransferableContent: TransferableContent? = null
208         var parentTransferableContent: TransferableContent? = null
209         inputMethodInterceptor.setContent {
210             BasicTextField(
211                 state = rememberTextFieldState(),
212                 modifier =
213                     Modifier.testTag(tag)
214                         .contentReceiver {
215                             parentTransferableContent = it
216                             null
217                         }
218                         .contentReceiver {
219                             childTransferableContent = it
220                             it
221                         }
222             )
223         }
224         rule.onNodeWithTag(tag).requestFocus()
225         inputMethodInterceptor.onIdle { editorInfo, inputConnection ->
226             InputConnectionCompat.commitContent(
227                 inputConnection,
228                 editorInfo,
229                 createInputContentInfo(),
230                 0,
231                 null
232             )
233         }
234 
235         rule.runOnIdle {
236             assertThat(childTransferableContent).isNotNull()
237             assertThat(childTransferableContent).isSameInstanceAs(parentTransferableContent)
238 
239             assertThat(parentTransferableContent?.source)
240                 .isEqualTo(TransferableContent.Source.Keyboard)
241             assertThat(parentTransferableContent?.clipMetadata?.clipDescription)
242                 .isEqualTo(DEFAULT_CLIP_DESCRIPTION)
243 
244             assertThat(parentTransferableContent?.clipEntry?.clipData?.itemCount).isEqualTo(1)
245             assertThat(parentTransferableContent?.clipEntry?.clipData?.getItemAt(0)?.uri)
246                 .isEqualTo(DEFAULT_CONTENT_URI)
247         }
248     }
249 
250     @Test
251     fun multiReceiveContent_doesNotCallParent_ifAllItemsAreProcessed() {
252         var childTransferableContent: TransferableContent? = null
253         var parentTransferableContent: TransferableContent? = null
254         inputMethodInterceptor.setContent {
255             BasicTextField(
256                 state = rememberTextFieldState(),
257                 modifier =
258                     Modifier.testTag(tag)
259                         .contentReceiver {
260                             parentTransferableContent = it
261                             null
262                         }
263                         .contentReceiver {
264                             childTransferableContent = it
265                             null
266                         }
267             )
268         }
269         rule.onNodeWithTag(tag).requestFocus()
270         inputMethodInterceptor.onIdle { editorInfo, inputConnection ->
271             InputConnectionCompat.commitContent(
272                 inputConnection,
273                 editorInfo,
274                 createInputContentInfo(),
275                 0,
276                 null
277             )
278         }
279 
280         rule.runOnIdle {
281             assertThat(childTransferableContent).isNotNull()
282             assertThat(parentTransferableContent).isNull()
283         }
284     }
285 
286     @Test
287     fun semanticsPasteContent_delegatesToReceiveContent() = runTest {
288         val clipboard = FakeClipboard(supportsClipEntry = true)
289         val clipEntry = createClipData().toClipEntry()
290         clipboard.setClipEntry(clipEntry)
291         lateinit var transferableContent: TransferableContent
292         rule.setContent {
293             CompositionLocalProvider(LocalClipboard provides clipboard) {
294                 BasicTextField(
295                     state = rememberTextFieldState(),
296                     modifier =
297                         Modifier.testTag(tag).contentReceiver {
298                             transferableContent = it
299                             null
300                         }
301                 )
302             }
303         }
304 
305         rule.onNode(hasSetTextAction()).performSemanticsAction(SemanticsActions.PasteText)
306 
307         rule.runOnIdle {
308             assertClipData(transferableContent.clipEntry.clipData)
309                 .isEqualToClipData(clipEntry.clipData)
310         }
311     }
312 
313     @Test
314     fun semanticsPasteContent_pastesLeftOverText() = runTest {
315         val clipboard = FakeClipboard(supportsClipEntry = true)
316         val clipEntry =
317             createClipData {
318                     addText("some text")
319                     addUri()
320                     addIntent()
321                     addText("more text")
322                 }
323                 .toClipEntry()
324         clipboard.setClipEntry(clipEntry)
325         val state = TextFieldState()
326         rule.setContent {
327             CompositionLocalProvider(LocalClipboard provides clipboard) {
328                 BasicTextField(
329                     state = state,
330                     modifier =
331                         Modifier.testTag(tag).contentReceiver {
332                             it.consume { item ->
333                                 // only consume if there's no text
334                                 item.text == null
335                             }
336                         }
337                 )
338             }
339         }
340 
341         rule.onNode(hasSetTextAction()).performSemanticsAction(SemanticsActions.PasteText)
342 
343         rule.runOnIdle { assertThat(state.text.toString()).isEqualTo("some text\nmore text") }
344     }
345 
346     @Test
347     fun semanticsPasteContent_goesFromChildToParent() = runTest {
348         val clipboard = FakeClipboard(supportsClipEntry = true)
349         val clipEntry =
350             createClipData {
351                     addText("a")
352                     addText("b")
353                     addText("c")
354                     addText("d")
355                 }
356                 .toClipEntry()
357         clipboard.setClipEntry(clipEntry)
358 
359         lateinit var transferableContent1: TransferableContent
360         lateinit var transferableContent2: TransferableContent
361         lateinit var transferableContent3: TransferableContent
362         val state = TextFieldState()
363 
364         rule.setContent {
365             CompositionLocalProvider(LocalClipboard provides clipboard) {
366                 BasicTextField(
367                     state = state,
368                     modifier =
369                         Modifier.testTag(tag)
370                             .contentReceiver { content ->
371                                 transferableContent1 = content
372                                 content.consume { it.text.contains("a") }
373                             }
374                             .contentReceiver { content ->
375                                 transferableContent2 = content
376                                 content.consume { it.text.contains("b") }
377                             }
378                             .contentReceiver { content ->
379                                 transferableContent3 = content
380                                 content.consume { it.text.contains("c") }
381                             }
382                 )
383             }
384         }
385 
386         rule.onNode(hasSetTextAction()).performSemanticsAction(SemanticsActions.PasteText)
387 
388         rule.runOnIdle {
389             assertThat(state.text.toString()).isEqualTo("d")
390             assertThat(transferableContent3.clipEntry.clipData.itemCount).isEqualTo(4)
391             assertThat(transferableContent2.clipEntry.clipData.itemCount).isEqualTo(3)
392             assertThat(transferableContent1.clipEntry.clipData.itemCount).isEqualTo(2)
393         }
394     }
395 
396     @Test
397     fun toolbarPasteContent_delegatesToReceiveContent() = runTest {
398         val clipboard = FakeClipboard(supportsClipEntry = true)
399         val clipEntry = createClipData().toClipEntry()
400         clipboard.setClipEntry(clipEntry)
401         var pasteOption: (() -> Unit)? = null
402         val textToolbar =
403             FakeTextToolbar(
404                 onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOption = onPasteRequested },
405                 onHideMenu = {}
406             )
407         lateinit var transferableContent: TransferableContent
408         rule.setContent {
409             CompositionLocalProvider(
410                 LocalClipboard provides clipboard,
411                 LocalTextToolbar provides textToolbar
412             ) {
413                 BasicTextField(
414                     state = rememberTextFieldState(),
415                     modifier =
416                         Modifier.testTag(tag).contentReceiver {
417                             transferableContent = it
418                             null
419                         }
420                 )
421             }
422         }
423 
424         rule.runOnIdle { pasteOption?.invoke() }
425 
426         rule.onNode(hasSetTextAction()).performSemanticsAction(SemanticsActions.PasteText)
427 
428         rule.runOnIdle {
429             assertClipData(transferableContent.clipEntry.clipData)
430                 .isEqualToClipData(clipEntry.clipData)
431         }
432     }
433 
434     companion object {
435         private val DEFAULT_CONTENT_URI = Uri.parse("content://com.example.app/content")
436         private val DEFAULT_CLIP_DESCRIPTION = ClipDescription("image", arrayOf("image/jpeg"))
437 
438         private fun createInputContentInfo(
439             contentUri: Uri = DEFAULT_CONTENT_URI,
440             clipDescription: ClipDescription = DEFAULT_CLIP_DESCRIPTION,
441             linkUri: Uri? = null
442         ) = InputContentInfoCompat(contentUri, clipDescription, linkUri)
443 
444         private fun InputMethodInterceptor.onIdle(block: (EditorInfo, InputConnection) -> Unit) {
445             withInputConnection {
446                 withEditorInfo { block(this@withEditorInfo, this@withInputConnection) }
447             }
448         }
449     }
450 }
451