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.os.Looper 20 import android.view.inputmethod.EditorInfo 21 import android.view.inputmethod.InputConnection 22 import androidx.compose.foundation.internal.checkPreconditionNotNull 23 import androidx.compose.runtime.Composable 24 import androidx.compose.ui.ExperimentalComposeUiApi 25 import androidx.compose.ui.platform.InterceptPlatformTextInput 26 import androidx.compose.ui.platform.PlatformTextInputInterceptor 27 import androidx.compose.ui.platform.PlatformTextInputMethodRequest 28 import androidx.compose.ui.platform.PlatformTextInputSession 29 import androidx.compose.ui.test.junit4.ComposeContentTestRule 30 import com.google.common.truth.IntegerSubject 31 import com.google.common.truth.Truth.assertThat 32 import com.google.common.truth.Truth.assertWithMessage 33 import kotlin.reflect.KClass 34 import kotlin.test.assertNotNull 35 import kotlinx.coroutines.awaitCancellation 36 37 /** 38 * Helper class for testing integration of BasicTextField and Legacy BasicTextField with the 39 * platform IME. 40 */ 41 @OptIn(ExperimentalComposeUiApi::class) 42 class InputMethodInterceptor(private val rule: ComposeContentTestRule) { 43 44 private var currentRequest: PlatformTextInputMethodRequest? = null 45 private val editorInfo = EditorInfo() 46 private var inputConnection: InputConnection? = null 47 private val interceptor = PlatformTextInputInterceptor { request, _ -> 48 currentRequest = request 49 sessionCount++ 50 try { 51 inputConnection = request.createInputConnection(editorInfo) 52 // Don't forward the request, block it, so that tests don't have to deal with the actual 53 // IME sending commands. 54 awaitCancellation() 55 } finally { 56 currentRequest = null 57 inputConnection = null 58 } 59 } 60 61 /** 62 * The total number of sessions that have been requested on this interceptor, including the 63 * current one if active. 64 */ 65 private var sessionCount = 0 66 67 /** 68 * Asserts that there is an active session. 69 * 70 * Can be called from any thread, including main and test runner. 71 */ 72 fun assertSessionActive() { 73 runOnIdle { 74 assertWithMessage("Expected a text input session to be active") 75 .that(currentRequest) 76 .isNotNull() 77 } 78 } 79 80 /** 81 * Asserts that there is no active session. 82 * 83 * Can be called from any thread, including main and test runner. 84 */ 85 fun assertNoSessionActive() { 86 runOnIdle { 87 assertWithMessage("Expected no text input session to be active") 88 .that(currentRequest) 89 .isNull() 90 } 91 } 92 93 /** 94 * Returns a subject that will assert on the total number of sessions requested on this 95 * interceptor, including the current one if active. 96 */ 97 fun assertThatSessionCount(): IntegerSubject = assertThat(runOnIdle { sessionCount }) 98 99 /** 100 * Runs [block] on the main thread and passes it the [PlatformTextInputMethodRequest] for the 101 * current input session. 102 * 103 * @throws AssertionError if no session is active. 104 */ 105 inline fun <reified T : PlatformTextInputMethodRequest> withCurrentRequest( 106 noinline block: T.() -> Unit 107 ) { 108 withCurrentRequest(T::class, block) 109 } 110 111 /** 112 * Runs [block] on the main thread and passes it the [PlatformTextInputMethodRequest] for the 113 * current input session. 114 * 115 * @throws AssertionError if no session is active. 116 */ 117 fun <T : PlatformTextInputMethodRequest> withCurrentRequest( 118 asClass: KClass<T>, 119 block: T.() -> Unit 120 ) { 121 runOnIdle { 122 val currentRequest = 123 assertNotNull(currentRequest, "Expected a text input session to be active") 124 assertThat(currentRequest).isInstanceOf(asClass.java) 125 @Suppress("UNCHECKED_CAST") block(currentRequest as T) 126 } 127 } 128 129 /** 130 * Runs [block] on the main thread and passes it the [EditorInfo] configured by the current 131 * input session. 132 * 133 * @throws AssertionError if no session is active. 134 */ 135 fun withEditorInfo(block: EditorInfo.() -> Unit) { 136 runOnIdle { 137 assertWithMessage("Expected a text input session to be active") 138 .that(currentRequest) 139 .isNotNull() 140 block(editorInfo) 141 } 142 } 143 144 /** 145 * Runs [block] on the main thread and passes it the [InputConnection] created by the current 146 * input session. 147 * 148 * @throws AssertionError if no session is active. 149 */ 150 fun withInputConnection(block: InputConnection.() -> Unit) { 151 runOnIdle { 152 val inputConnection = 153 checkPreconditionNotNull(inputConnection) { 154 "Tried to read inputConnection while no session was active" 155 } 156 block(inputConnection) 157 } 158 } 159 160 /** 161 * Sets the content of the test, overriding the [PlatformTextInputSession] handler. 162 * 163 * This is just a convenience method for calling `rule.setContent` and then calling this class's 164 * [Content] method yourself. 165 */ 166 fun setContent(content: @Composable () -> Unit) { 167 rule.setContent { Content(content) } 168 } 169 170 /** 171 * Wraps the content of the test to override the [PlatformTextInputSession] handler. 172 * 173 * @see setContent 174 */ 175 @OptIn(ExperimentalComposeUiApi::class) 176 @Composable 177 fun Content(content: @Composable () -> Unit) { 178 InterceptPlatformTextInput(interceptor, content) 179 } 180 181 private fun <T> runOnIdle(block: () -> T): T { 182 return if (Looper.myLooper() != Looper.getMainLooper()) { 183 rule.runOnIdle(block) 184 } else { 185 block() 186 } 187 } 188 } 189