1 /*
<lambda>null2  * Copyright 2025 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.contextmenu.provider
18 
19 import android.annotation.SuppressLint
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.clickable
22 import androidx.compose.foundation.layout.Arrangement
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.Column
25 import androidx.compose.foundation.layout.fillMaxSize
26 import androidx.compose.foundation.layout.offset
27 import androidx.compose.foundation.layout.padding
28 import androidx.compose.foundation.layout.size
29 import androidx.compose.foundation.layout.sizeIn
30 import androidx.compose.foundation.text.BasicText
31 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuData
32 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItem
33 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession
34 import androidx.compose.foundation.text.test.assertThatJob
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.compositionLocalOf
37 import androidx.compose.runtime.getValue
38 import androidx.compose.runtime.mutableIntStateOf
39 import androidx.compose.runtime.mutableStateOf
40 import androidx.compose.runtime.rememberCoroutineScope
41 import androidx.compose.runtime.setValue
42 import androidx.compose.ui.Alignment
43 import androidx.compose.ui.Modifier
44 import androidx.compose.ui.geometry.Offset
45 import androidx.compose.ui.geometry.Rect
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.layout.LayoutCoordinates
48 import androidx.compose.ui.layout.boundsInRoot
49 import androidx.compose.ui.platform.testTag
50 import androidx.compose.ui.test.assertIsDisplayed
51 import androidx.compose.ui.test.junit4.createComposeRule
52 import androidx.compose.ui.test.onNodeWithTag
53 import androidx.compose.ui.unit.IntOffset
54 import androidx.compose.ui.unit.dp
55 import androidx.compose.ui.unit.roundToIntRect
56 import androidx.compose.ui.unit.toIntRect
57 import androidx.compose.ui.unit.toOffset
58 import androidx.compose.ui.util.fastForEach
59 import com.google.common.truth.Truth.assertThat
60 import kotlin.test.assertFailsWith
61 import kotlinx.coroutines.CancellationException
62 import kotlinx.coroutines.CoroutineScope
63 import kotlinx.coroutines.Job
64 import kotlinx.coroutines.launch
65 import org.junit.Rule
66 import org.junit.Test
67 
68 class BasicTextContextMenuProviderTest {
69     @get:Rule val rule = createComposeRule()
70 
71     @Test
72     fun whenDefault_expectedItemsAppear() = runProviderTest {
73         showTextContextMenu(testDataProvider(1, 2))
74         assertContextMenuExistsWithNumbers(1, 2)
75     }
76 
77     @Test
78     fun whenSessionCloseCalled_contextMenuDisappears() = runProviderTest {
79         val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
80         assertContextMenuExistsWithNumbers(1)
81         assertThatJob(contextMenuCoroutine).isActive()
82 
83         assertNotNull(session).close()
84         rule.waitForIdle()
85 
86         assertContextMenuDoesNotExist()
87         assertThatJob(contextMenuCoroutine).isCompleted()
88     }
89 
90     @Test
91     fun whenCoroutineCancelled_contextMenuDisappears() = runProviderTest {
92         val contextMenuCoroutine = launch {
93             assertFailsWith<CancellationException> {
94                 assertNotNull(provider).showTextContextMenu(testDataProvider(1))
95             }
96         }
97         assertContextMenuExistsWithNumbers(1)
98         assertThatJob(contextMenuCoroutine).isActive()
99 
100         contextMenuCoroutine.cancel()
101         rule.waitForIdle()
102 
103         assertContextMenuDoesNotExist()
104         assertThatJob(contextMenuCoroutine).isCancelled()
105     }
106 
107     @Test
108     fun whenCallingShowTwice_contextMenuIsReplaced() = runProviderTest {
109         val firstContextMenuCoroutine = showTextContextMenu(testDataProvider(1))
110         assertContextMenuExistsWithNumbers(1)
111         assertContextMenuItemsWithNumbersDoNotExist(2)
112         assertThatJob(firstContextMenuCoroutine).isActive()
113 
114         val secondContextMenuCoroutine = showTextContextMenu(testDataProvider(2))
115         assertContextMenuItemsWithNumbersDoNotExist(1)
116         assertContextMenuExistsWithNumbers(2)
117         assertThatJob(firstContextMenuCoroutine).isCompleted()
118         assertThatJob(secondContextMenuCoroutine).isActive()
119     }
120 
121     @Test
122     fun whenRemovingAnchorLayout_contextMenuIsClosed() {
123         var showAnchorLayout by mutableStateOf(true)
124         runProviderTest(
125             outerContent = { content -> OuterBox { if (showAnchorLayout) content() } },
126         ) {
127             rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
128 
129             val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
130 
131             rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
132             assertContextMenuExistsWithNumbers(1)
133             assertThatJob(contextMenuCoroutine).isActive()
134 
135             showAnchorLayout = false
136             rule.waitForIdle()
137 
138             rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
139             assertContextMenuDoesNotExist()
140             assertThatJob(contextMenuCoroutine).isCompleted()
141         }
142     }
143 
144     @Test
145     fun whenRemovingProvider_contextMenuIsClosed() = runProviderTest {
146         rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
147 
148         val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
149 
150         rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
151         assertContextMenuExistsWithNumbers(1)
152         assertThatJob(contextMenuCoroutine).isActive()
153 
154         enabled = false
155         rule.waitForIdle()
156 
157         rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
158         assertContextMenuDoesNotExist()
159         assertThatJob(contextMenuCoroutine).isCompleted()
160     }
161 
162     @Test
163     fun whenShowingThenInstantlyClosing_coroutineDoesNotHang() = runProviderTest {
164         val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
165         enabled = false
166         rule.waitForIdle()
167 
168         rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
169         assertContextMenuDoesNotExist()
170         assertThatJob(contextMenuCoroutine).isCompleted()
171     }
172 
173     @Test
174     fun whenMovingAnchorLayout_contextMenuReceivesUpdate() {
175         var length by mutableIntStateOf(0)
176         runProviderTest(
177             outerContent = { content ->
178                 OuterBox(Modifier.offset { IntOffset(length, length) }.size(150.dp)) { content() }
179             },
180         ) {
181             val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
182 
183             assertContextMenuExistsWithNumbers(1)
184             assertThatJob(contextMenuCoroutine).isActive()
185             val initialBounds = anchorLayoutCoordinates.boundsInRoot().roundToIntRect()
186 
187             length = 50
188             rule.waitForIdle()
189 
190             assertContextMenuExistsWithNumbers(1)
191             assertThatJob(contextMenuCoroutine).isActive()
192 
193             val finalBounds = anchorLayoutCoordinates.boundsInRoot().roundToIntRect()
194             val expectedBounds = initialBounds.translate(IntOffset(50, 50))
195             assertThat(finalBounds).isEqualTo(expectedBounds)
196         }
197     }
198 
199     @Test
200     fun whenContextMenuChanges_contextMenuUpdates() {
201         lateinit var coroutineScope: CoroutineScope
202         lateinit var provider: TextContextMenuProvider
203 
204         fun contextMenuFunction(
205             tag: String
206         ): @Composable
207         (
208             session: TextContextMenuSession,
209             dataProvider: TextContextMenuDataProvider,
210             anchorLayoutCoordinates: () -> LayoutCoordinates,
211         ) -> Unit = { _, _, _ ->
212             Box(modifier = Modifier.background(Color.LightGray).size(50.dp).testTag(tag))
213         }
214 
215         val tag1 = "ContextMenu1"
216         val tag2 = "ContextMenu2"
217         var contextMenu by mutableStateOf(contextMenuFunction(tag1))
218 
219         rule.setContent {
220             coroutineScope = rememberCoroutineScope()
221             OuterBox {
222                 ProvideBasicTextContextMenu(
223                     modifier = Modifier.testTag(AnchorLayoutTag),
224                     providableCompositionLocal = LocalTestContextMenuProvider,
225                     contextMenu = contextMenu
226                 ) {
227                     provider = LocalTestContextMenuProvider.current!!
228                     InnerBox()
229                 }
230             }
231         }
232 
233         val job1 = coroutineScope.launch { provider.showTextContextMenu(testDataProvider(1)) }
234         rule.waitForIdle()
235 
236         rule.onNodeWithTag(tag1).assertIsDisplayed()
237         rule.onNodeWithTag(tag2).assertDoesNotExist()
238         assertThatJob(job1).isActive()
239 
240         contextMenu = contextMenuFunction(tag2)
241         rule.waitForIdle()
242 
243         rule.onNodeWithTag(tag1).assertDoesNotExist()
244         rule.onNodeWithTag(tag2).assertDoesNotExist()
245         assertThatJob(job1).isCompleted()
246 
247         val job2 = coroutineScope.launch { provider.showTextContextMenu(testDataProvider(1)) }
248         rule.waitForIdle()
249 
250         rule.onNodeWithTag(tag1).assertDoesNotExist()
251         rule.onNodeWithTag(tag2).assertIsDisplayed()
252         assertThatJob(job2).isActive()
253     }
254 
255     /**
256      * @param outerContent Content that goes around the context menu provider, must call the content
257      *   lambda exactly once
258      * @param innerContent Content that goes inside the context menu provider
259      * @param testBlock actions and assertions to run after the content is set
260      */
261     private fun runProviderTest(
262         outerContent: @Composable (content: @Composable () -> Unit) -> Unit = { content ->
263             OuterBox(content = content)
264         },
265         innerContent: @Composable () -> Unit = { InnerBox() },
266         testBlock: TestScope.() -> Unit,
267     ) {
268         val testScope = TestScope()
269         rule.setContent {
270             testScope.coroutineScope = rememberCoroutineScope()
271             outerContent {
272                 if (testScope.enabled) {
273                     ProvideTestBasicTextContextMenu(
274                         onContextMenuComposition = { session, anchorLayoutCoordinates ->
275                             testScope.session = session
276                             testScope.anchorLayoutCoordinatesFunction = anchorLayoutCoordinates
277                         }
278                     ) {
279                         testScope.provider = LocalTestContextMenuProvider.current
280                         innerContent()
281                     }
282                 } else {
283                     innerContent()
284                 }
285             }
286         }
287 
288         testScope.testBlock()
289     }
290 
291     private inner class TestScope {
292         var anchorLayoutCoordinatesFunction: (() -> LayoutCoordinates)? = null
293         val anchorLayoutCoordinates: LayoutCoordinates
294             get() = assertNotNull(anchorLayoutCoordinatesFunction).invoke()
295 
296         var coroutineScope: CoroutineScope? = null
297         var provider: TextContextMenuProvider? = null
298         var session: TextContextMenuSession? = null
299         var enabled by mutableStateOf(true)
300 
301         fun launch(block: suspend CoroutineScope.() -> Unit): Job =
302             assertNotNull(coroutineScope).launch(block = block)
303 
304         fun showTextContextMenu(dataProvider: TextContextMenuDataProvider): Job = launch {
305             assertNotNull(provider).showTextContextMenu(dataProvider)
306         }
307 
308         fun assertContextMenuExistsWithNumbers(vararg itemNumbers: Int) {
309             rule.onNodeWithTag(ContextMenuTag).assertIsDisplayed()
310             itemNumbers.forEach { rule.onNodeWithTag("$it").assertIsDisplayed() }
311         }
312 
313         fun assertContextMenuItemsWithNumbersDoNotExist(vararg itemNumbers: Int) {
314             itemNumbers.forEach { rule.onNodeWithTag("$it").assertDoesNotExist() }
315         }
316 
317         fun assertContextMenuDoesNotExist() {
318             rule.onNodeWithTag(ContextMenuTag).assertDoesNotExist()
319         }
320     }
321 }
322 
<lambda>null323 private fun <T : Any> assertNotNull(obj: T?): T = obj.also { assertThat(it).isNotNull() }!!
324 
<lambda>null325 private val LocalTestContextMenuProvider = compositionLocalOf<TextContextMenuProvider?> { null }
326 
327 private const val AnchorLayoutTag = "AnchorLayout"
328 private const val ContextMenuTag = "ContextMenu"
329 
330 @Composable
331 @SuppressLint("ModifierParameter")
OuterBoxnull332 private fun OuterBox(modifier: Modifier = Modifier.fillMaxSize(), content: @Composable () -> Unit) {
333     Box(modifier, Alignment.Center) { content() }
334 }
335 
336 @Composable
InnerBoxnull337 private fun InnerBox() {
338     Box(
339         Modifier.background(Color.LightGray.copy(alpha = 0.3f))
340             .sizeIn(minWidth = 200.dp, minHeight = 200.dp)
341     )
342 }
343 
344 @Composable
ProvideTestBasicTextContextMenunull345 private fun ProvideTestBasicTextContextMenu(
346     onContextMenuComposition:
347         (
348             session: TextContextMenuSession?,
349             anchorLayoutCoordinates: () -> LayoutCoordinates,
350         ) -> Unit,
351     content: @Composable () -> Unit
352 ) {
353     ProvideBasicTextContextMenu(
354         modifier = Modifier.testTag(AnchorLayoutTag),
355         providableCompositionLocal = LocalTestContextMenuProvider,
356         contextMenu = { session, dataProvider, anchorLayoutCoordinates ->
357             onContextMenuComposition(session, anchorLayoutCoordinates)
358             TestContextMenu(session, dataProvider)
359         },
360         content = content,
361     )
362 }
363 
364 @Composable
TestContextMenunull365 private fun TestContextMenu(
366     session: TextContextMenuSession,
367     dataProvider: TextContextMenuDataProvider,
368 ) {
369     Column(
370         verticalArrangement = Arrangement.spacedBy(4.dp),
371         modifier = Modifier.testTag(ContextMenuTag).background(Color.LightGray).padding(4.dp)
372     ) {
373         dataProvider.data().components.fastForEach {
374             when (it) {
375                 is TextContextMenuItem ->
376                     BasicText(
377                         text = it.label,
378                         modifier = Modifier.testTag(it.label).clickable { it.onClick(session) }
379                     )
380             }
381         }
382     }
383 }
384 
testDataProvidernull385 private fun testDataProvider(vararg itemNumbers: Int): TextContextMenuDataProvider =
386     object : TextContextMenuDataProvider {
387         override fun position(destinationCoordinates: LayoutCoordinates): Offset =
388             destinationCoordinates.size.toIntRect().center.toOffset()
389 
390         override fun contentBounds(destinationCoordinates: LayoutCoordinates): Rect =
391             position(destinationCoordinates).let { Rect(it, it) }
392 
393         override fun data(): TextContextMenuData =
394             TextContextMenuData(
395                 itemNumbers.map {
396                     TextContextMenuItem(
397                         key = it,
398                         label = "$it",
399                         onClick = fun TextContextMenuSession.() {}
400                     )
401                 }
402             )
403     }
404