1 /*
<lambda>null2 * Copyright 2023 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.ui.platform
18
19 import androidx.compose.runtime.Composable
20 import androidx.compose.runtime.CompositionLocalProvider
21 import androidx.compose.runtime.Stable
22 import androidx.compose.runtime.getValue
23 import androidx.compose.runtime.mutableStateOf
24 import androidx.compose.runtime.remember
25 import androidx.compose.runtime.setValue
26 import androidx.compose.runtime.snapshotFlow
27 import androidx.compose.runtime.staticCompositionLocalOf
28 import androidx.compose.ui.ExperimentalComposeUiApi
29 import androidx.compose.ui.InternalComposeUiApi
30 import androidx.compose.ui.Modifier
31 import androidx.compose.ui.SessionMutex
32 import androidx.compose.ui.node.DelegatableNode
33 import androidx.compose.ui.node.Owner
34 import androidx.compose.ui.node.requireLayoutNode
35 import androidx.compose.ui.node.requireOwner
36 import kotlinx.coroutines.CoroutineScope
37 import kotlinx.coroutines.flow.collectLatest
38
39 /**
40 * A modifier node that can connect to the platform's text input IME system. To initiate a text
41 * input session, call [establishTextInputSession].
42 *
43 * @sample androidx.compose.ui.samples.platformTextInputModifierNodeSample
44 */
45 interface PlatformTextInputModifierNode : DelegatableNode
46
47 /** Receiver type for [establishTextInputSession]. */
48 expect interface PlatformTextInputSession {
49 /**
50 * Starts the text input session and suspends until it is closed.
51 *
52 * On platforms that support software keyboards, calling this method will show the keyboard and
53 * attempt to keep it visible until the last session is closed.
54 *
55 * Calling this method multiple times, within the same [establishTextInputSession] block or from
56 * different [establishTextInputSession]s, will restart the session each time.
57 *
58 * @param request The platform-specific [PlatformTextInputMethodRequest] that will be used to
59 * initiate the session.
60 */
61 suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing
62 }
63
64 /**
65 * A [PlatformTextInputSession] that is also a [CoroutineScope]. This type should _only_ be used as
66 * the receiver of the function passed to [establishTextInputSession]. Other extension functions
67 * that need to get the scope should _not_ use this as their receiver type, instead they should be
68 * suspend functions with a [PlatformTextInputSession] receiver. If they need a [CoroutineScope]
69 * they should call the [kotlinx.coroutines.coroutineScope] function.
70 */
71 interface PlatformTextInputSessionScope : PlatformTextInputSession, CoroutineScope
72
73 /** Single-function interface passed to [InterceptPlatformTextInput]. */
74 @ExperimentalComposeUiApi
interfacenull75 fun interface PlatformTextInputInterceptor {
76
77 /**
78 * Called when a function passed to
79 * [establishTextInputSession][PlatformTextInputModifierNode.establishTextInputSession] calls
80 * [startInputMethod][PlatformTextInputSession.startInputMethod]. The
81 * [PlatformTextInputMethodRequest] from the caller is passed to this function as [request], and
82 * this function can either respond to the request directly (e.g. recording it for a test), or
83 * wrap the request and pass it to [nextHandler]. This function _must_ call into [nextHandler]
84 * if it intends for the system to respond to the request. Not calling into [nextHandler] has
85 * the effect of blocking the request.
86 *
87 * This method has the same ordering guarantees as
88 * [startInputMethod][PlatformTextInputSession.startInputMethod]. That is, for a given text
89 * input modifier, if [startInputMethod][PlatformTextInputSession.startInputMethod] is called
90 * multiple times, only one [interceptStartInputMethod] call will be made at a time, and any
91 * previous call will be allowed to finish running any `finally` blocks before the new session
92 * starts.
93 */
94 suspend fun interceptStartInputMethod(
95 request: PlatformTextInputMethodRequest,
96 nextHandler: PlatformTextInputSession
97 ): Nothing
98 }
99
100 /**
101 * Starts a new text input session and suspends until the session is closed.
102 *
103 * The [block] function must call [PlatformTextInputSession.startInputMethod] to actually show and
104 * initiate the connection with the input method. If it does not, the session will end when this
105 * function returns without showing the input method.
106 *
107 * If this function is called while another session is active, the sessions will not overlap. The
108 * new session will interrupt the old one, which will be cancelled and allowed to finish running any
109 * cancellation tasks (e.g. `finally` blocks) before running the new [block] function.
110 *
111 * The session will be closed when:
112 * - The session function throws an exception.
113 * - The requesting coroutine is cancelled.
114 * - Another session is started via this method, either from the same modifier or a different one.
115 * The session may remain open when:
116 * - The system closes the connection. This behavior currently only exists on Android depending on
117 * OS version. Android platform may intermittently close the active connection to immediately
118 * start it back again. In these cases the session will not be prematurely closed, so that it can
119 * serve the follow-up requests.
120 *
121 * This function should only be called from the modifier node's
122 * [coroutineScope][Modifier.Node.coroutineScope]. If it is not, the session will _not_
123 * automatically be closed if the modifier is detached.
124 *
125 * @sample androidx.compose.ui.samples.platformTextInputModifierNodeSample
126 * @param block A suspend function that will be called when the session is started and that must
127 * call [PlatformTextInputSession.startInputMethod] to actually show and initiate the connection
128 * with the input method.
129 */
establishTextInputSessionnull130 suspend fun PlatformTextInputModifierNode.establishTextInputSession(
131 block: suspend PlatformTextInputSessionScope.() -> Nothing
132 ): Nothing {
133 require(node.isAttached) { "establishTextInputSession called from an unattached node" }
134 val owner = requireOwner()
135 val handler = requireLayoutNode().compositionLocalMap[LocalChainedPlatformTextInputInterceptor]
136 owner.interceptedTextInputSession(handler, block)
137 }
138
139 /**
140 * Intercept all calls to [PlatformTextInputSession.startInputMethod] from below where this
141 * composition local is provided with the given [PlatformTextInputInterceptor].
142 *
143 * If a different interceptor instance is passed between compositions while a text input session is
144 * active, the upstream session will be torn down and restarted with the new interceptor. The
145 * downstream session (i.e. the call to [PlatformTextInputSession.startInputMethod]) will _not_ be
146 * cancelled and the request will be re-used to pass to the new interceptor.
147 *
148 * @sample androidx.compose.ui.samples.InterceptPlatformTextInputSample
149 * @sample androidx.compose.ui.samples.disableSoftKeyboardSample
150 */
151 @ExperimentalComposeUiApi
152 @Composable
InterceptPlatformTextInputnull153 fun InterceptPlatformTextInput(
154 interceptor: PlatformTextInputInterceptor,
155 content: @Composable () -> Unit
156 ) {
157 val parent = LocalChainedPlatformTextInputInterceptor.current
158 // We don't need to worry about explicitly cancelling the input session if the parent changes:
159 // The only way the parent can change is if the entire subtree of the composition is moved,
160 // which means the PlatformTextInputModifierNode would be detached/reattached, and the node
161 // should cancel its input session when it's detached.
162 val chainedInterceptor =
163 remember(parent) { ChainedPlatformTextInputInterceptor(interceptor, parent) }
164
165 // If the interceptor changes while an input session is active, the upstream session will be
166 // restarted and the downstream one will not be cancelled.
167 chainedInterceptor.updateInterceptor(interceptor)
168
169 CompositionLocalProvider(
170 LocalChainedPlatformTextInputInterceptor provides chainedInterceptor,
171 content = content
172 )
173 }
174
175 private val LocalChainedPlatformTextInputInterceptor =
<lambda>null176 staticCompositionLocalOf<ChainedPlatformTextInputInterceptor?> { null }
177
178 /** Establishes a new text input session, optionally intercepted by [chainedInterceptor]. */
interceptedTextInputSessionnull179 private suspend fun Owner.interceptedTextInputSession(
180 chainedInterceptor: ChainedPlatformTextInputInterceptor?,
181 session: suspend PlatformTextInputSessionScope.() -> Nothing
182 ): Nothing {
183 if (chainedInterceptor == null) {
184 textInputSession(session)
185 } else {
186 chainedInterceptor.textInputSession(this, session)
187 }
188 }
189
190 /**
191 * A link in a chain of [PlatformTextInputInterceptor]s. Knows about its [parent] and dispatches
192 * [textInputSession] calls up the chain.
193 */
194 @OptIn(ExperimentalComposeUiApi::class)
195 @Stable
196 private class ChainedPlatformTextInputInterceptor(
197 initialInterceptor: PlatformTextInputInterceptor,
198 private val parent: ChainedPlatformTextInputInterceptor?
199 ) {
200 private var interceptor by mutableStateOf(initialInterceptor)
201
updateInterceptornull202 fun updateInterceptor(interceptor: PlatformTextInputInterceptor) {
203 this.interceptor = interceptor
204 }
205
206 /**
207 * Intercepts the text input session with [interceptor] and dispatches up the chain, ultimately
208 * terminating at the [Owner].
209 *
210 * Note that the interceptor chain is assembled across the entire composition, including any
211 * subcompositions, but [owner] must be the [Owner] that directly hosts the
212 * [PlatformTextInputModifierNode] establishing the session.
213 */
214 @OptIn(InternalComposeUiApi::class)
textInputSessionnull215 suspend fun textInputSession(
216 owner: Owner,
217 session: suspend PlatformTextInputSessionScope.() -> Nothing
218 ): Nothing {
219 owner.interceptedTextInputSession(parent) {
220 val parentSession = this
221 val inputMethodMutex = SessionMutex<Unit>()
222
223 // Impl by delegation for platform-specific stuff.
224 val scope =
225 object : PlatformTextInputSessionScope by parentSession {
226 override suspend fun startInputMethod(
227 request: PlatformTextInputMethodRequest
228 ): Nothing {
229 // Explicitly synchronize between calls to our startInputMethod.
230 inputMethodMutex.withSessionCancellingPrevious<Nothing>(
231 sessionInitializer = {},
232 session = {
233 // Restart the upstream session if the interceptor is changed while
234 // the
235 // session is active.
236 snapshotFlow { interceptor }
237 .collectLatest { interceptor ->
238 interceptor.interceptStartInputMethod(
239 request,
240 parentSession
241 )
242 }
243 error("Interceptors flow should never terminate.")
244 }
245 )
246 }
247 }
248 session.invoke(scope)
249 }
250 }
251 }
252