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