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