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 com.android.photopicker.core.components
18 
19 import android.content.ContentProvider
20 import android.content.ContentResolver
21 import android.content.Intent
22 import android.net.Uri
23 import android.os.Build
24 import android.provider.MediaStore
25 import android.view.SurfaceControlViewHost
26 import androidx.compose.foundation.clickable
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.material3.Text
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.CompositionLocalProvider
31 import androidx.compose.runtime.getValue
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.platform.testTag
34 import androidx.compose.ui.semantics.semantics
35 import androidx.compose.ui.test.ExperimentalTestApi
36 import androidx.compose.ui.test.assertAll
37 import androidx.compose.ui.test.assertCountEquals
38 import androidx.compose.ui.test.assertIsDisplayed
39 import androidx.compose.ui.test.click
40 import androidx.compose.ui.test.filter
41 import androidx.compose.ui.test.hasContentDescription
42 import androidx.compose.ui.test.hasTestTag
43 import androidx.compose.ui.test.hasText
44 import androidx.compose.ui.test.junit4.createComposeRule
45 import androidx.compose.ui.test.longClick
46 import androidx.compose.ui.test.onChildren
47 import androidx.compose.ui.test.onFirst
48 import androidx.compose.ui.test.performClick
49 import androidx.compose.ui.test.performTouchInput
50 import androidx.compose.ui.test.swipeDown
51 import androidx.compose.ui.test.swipeUp
52 import androidx.lifecycle.compose.collectAsStateWithLifecycle
53 import androidx.paging.Pager
54 import androidx.paging.PagingConfig
55 import androidx.paging.PagingData
56 import androidx.paging.compose.collectAsLazyPagingItems
57 import androidx.test.filters.SdkSuppress
58 import androidx.test.platform.app.InstrumentationRegistry
59 import com.android.modules.utils.build.SdkLevel
60 import com.android.photopicker.R
61 import com.android.photopicker.core.ActivityModule
62 import com.android.photopicker.core.ApplicationModule
63 import com.android.photopicker.core.ApplicationOwned
64 import com.android.photopicker.core.Background
65 import com.android.photopicker.core.ConcurrencyModule
66 import com.android.photopicker.core.EmbeddedServiceModule
67 import com.android.photopicker.core.Main
68 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
69 import com.android.photopicker.core.configuration.PhotopickerConfiguration
70 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv
71 import com.android.photopicker.core.configuration.TestPhotopickerConfiguration
72 import com.android.photopicker.core.configuration.provideTestConfigurationFlow
73 import com.android.photopicker.core.embedded.EmbeddedState
74 import com.android.photopicker.core.embedded.LocalEmbeddedState
75 import com.android.photopicker.core.glide.GlideTestRule
76 import com.android.photopicker.core.selection.SelectionImpl
77 import com.android.photopicker.core.theme.PhotopickerTheme
78 import com.android.photopicker.data.TestDataServiceImpl
79 import com.android.photopicker.data.model.Group
80 import com.android.photopicker.data.model.Media
81 import com.android.photopicker.data.model.MediaPageKey
82 import com.android.photopicker.data.model.MediaSource
83 import com.android.photopicker.data.paging.FakeInMemoryAlbumPagingSource
84 import com.android.photopicker.data.paging.FakeInMemoryMediaPagingSource
85 import com.android.photopicker.extensions.insertMonthSeparators
86 import com.android.photopicker.extensions.toMediaGridItemFromAlbum
87 import com.android.photopicker.extensions.toMediaGridItemFromMedia
88 import com.android.photopicker.inject.PhotopickerTestModule
89 import com.android.photopicker.util.test.MockContentProviderWrapper
90 import com.android.photopicker.util.test.whenever
91 import com.google.common.truth.Truth.assertWithMessage
92 import dagger.Module
93 import dagger.hilt.InstallIn
94 import dagger.hilt.android.testing.BindValue
95 import dagger.hilt.android.testing.HiltAndroidRule
96 import dagger.hilt.android.testing.HiltAndroidTest
97 import dagger.hilt.android.testing.UninstallModules
98 import dagger.hilt.components.SingletonComponent
99 import java.time.LocalDateTime
100 import java.time.ZoneOffset
101 import java.time.temporal.ChronoUnit
102 import kotlinx.coroutines.CoroutineDispatcher
103 import kotlinx.coroutines.CoroutineScope
104 import kotlinx.coroutines.ExperimentalCoroutinesApi
105 import kotlinx.coroutines.flow.Flow
106 import kotlinx.coroutines.flow.flowOf
107 import kotlinx.coroutines.launch
108 import kotlinx.coroutines.test.StandardTestDispatcher
109 import kotlinx.coroutines.test.TestScope
110 import kotlinx.coroutines.test.advanceTimeBy
111 import kotlinx.coroutines.test.runTest
112 import org.junit.Before
113 import org.junit.Rule
114 import org.junit.Test
115 import org.mockito.Mock
116 import org.mockito.Mockito.any
117 import org.mockito.Mockito.atLeast
118 import org.mockito.Mockito.never
119 import org.mockito.Mockito.verify
120 import org.mockito.MockitoAnnotations
121 
122 /**
123  * Unit tests for the [MediaGrid] composables.
124  *
125  * Since [MediaGrid]'s default implementation uses Glide to load images, the [ApplicationModule] is
126  * uninstalled and this test mocks out Glide's dependencies to always return a test image.
127  *
128  * The data in this test suite is provided by [FakeInMemoryPagingSource] to isolate device state and
129  * avoid creating test images on the device itself. Metadata is generated in the paging source, and
130  * all images are backed by a test resource png that is provided by the content resolver mock.
131  */
132 @UninstallModules(
133     ActivityModule::class,
134     ApplicationModule::class,
135     ConcurrencyModule::class,
136     EmbeddedServiceModule::class,
137 )
138 @HiltAndroidTest
139 @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class)
140 class MediaGridTest {
141     /** Hilt's rule needs to come first to ensure the DI container is setup for the test. */
142     @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this)
143     @get:Rule(order = 1) val composeTestRule = createComposeRule()
144     @get:Rule(order = 2) val glideRule = GlideTestRule()
145 
146     /**
147      * MediaGrid uses Glide for loading images, so we have to mock out the dependencies for Glide
148      * Replace the injected ContentResolver binding in [ApplicationModule] with this test value.
149      */
150     @BindValue @ApplicationOwned lateinit var contentResolver: ContentResolver
151     private lateinit var provider: MockContentProviderWrapper
152 
153     /* Setup dependencies for the UninstallModules for the test class. */
154     @Module @InstallIn(SingletonComponent::class) class TestModule : PhotopickerTestModule()
155 
156     val testDispatcher = StandardTestDispatcher()
157 
158     /* Overrides for ActivityModule */
159     val testScope: TestScope = TestScope(testDispatcher)
160     @BindValue @Main val mainScope: CoroutineScope = testScope
161     @BindValue @Background var testBackgroundScope: CoroutineScope = testScope.backgroundScope
162 
163     /* Overrides for the ConcurrencyModule */
164     @BindValue @Main val mainDispatcher: CoroutineDispatcher = testDispatcher
165     @BindValue @Background val backgroundDispatcher: CoroutineDispatcher = testDispatcher
166 
167     @Mock lateinit var mockContentProvider: ContentProvider
168 
169     @Mock lateinit var mockSurfaceControlViewHost: SurfaceControlViewHost
170 
171     /**
172      * A [EmbeddedState] having a mocked [SurfaceControlViewHost] instance that can be used for
173      * testing in collapsed mode
174      */
175     private lateinit var testEmbeddedStateWithHostInCollapsedState: EmbeddedState
176 
177     /**
178      * A [EmbeddedState] having a mocked [SurfaceControlViewHost] instance that can be used for
179      * testing in Expanded state
180      */
181     private lateinit var testEmbeddedStateWithHostInExpandedState: EmbeddedState
182 
183     lateinit var pager: Pager<MediaPageKey, Media>
184     lateinit var flow: Flow<PagingData<MediaGridItem>>
185 
186     private val MEDIA_GRID_TEST_TAG = "media_grid"
187     private val BANNER_CONTENT_TEST_TAG = "banner_content"
188     private val CUSTOM_ITEM_TEST_TAG = "custom_item"
189     private val CUSTOM_ITEM_SEPARATOR_TAG = "custom_separator"
190     private val CUSTOM_ITEM_FACTORY_TEXT = "custom item factory"
191     private val CUSTOM_ITEM_SEPARATOR_TEXT = "custom item separator"
192 
193     private val FIRST_SEPARATOR_LABEL = "First"
194     private val SECOND_SEPARATOR_LABEL = "Second"
195 
196     private val MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING = "taken on"
197 
198     /* A small MediaGridItem list that includes two Separators with three MediaItems in between */
199     private val dataWithSeparators =
200         buildList<MediaGridItem>() {
201             add(MediaGridItem.SeparatorItem(FIRST_SEPARATOR_LABEL))
202             for (i in 1..3) {
203                 add(
204                     MediaGridItem.MediaItem(
205                         media =
206                             Media.Image(
207                                 mediaId = "$i",
208                                 pickerId = i.toLong(),
209                                 authority = "a",
210                                 mediaSource = MediaSource.LOCAL,
211                                 mediaUri =
212                                     Uri.EMPTY.buildUpon()
213                                         .apply {
214                                             scheme("content")
215                                             authority("media")
216                                             path("picker")
217                                             path("a")
218                                             path("$i")
219                                         }
220                                         .build(),
221                                 glideLoadableUri =
222                                     Uri.EMPTY.buildUpon()
223                                         .apply {
224                                             scheme("content")
225                                             authority("a")
226                                             path("$i")
227                                         }
228                                         .build(),
229                                 dateTakenMillisLong =
230                                     LocalDateTime.now()
231                                         .minus(i.toLong(), ChronoUnit.DAYS)
232                                         .toEpochSecond(ZoneOffset.UTC) * 1000,
233                                 sizeInBytes = 1000L,
234                                 mimeType = "image/png",
235                                 standardMimeTypeExtension = 1,
236                             )
237                     )
238                 )
239             }
240             add(MediaGridItem.SeparatorItem(SECOND_SEPARATOR_LABEL))
241         }
242 
243     @Before
244     fun setup() {
245         MockitoAnnotations.initMocks(this)
246 
247         // Stub out the content resolver for Glide
248         provider = MockContentProviderWrapper(mockContentProvider)
249         contentResolver = ContentResolver.wrap(provider)
250 
251         // Return a resource png so that glide actually has something to load
252         whenever(mockContentProvider.openTypedAssetFile(any(), any(), any(), any())) {
253             InstrumentationRegistry.getInstrumentation()
254                 .getContext()
255                 .getResources()
256                 .openRawResourceFd(R.drawable.android)
257         }
258 
259         initEmbeddedStates()
260 
261         // Normally this would be created in the view model that owns the paged data.
262         pager =
263             Pager(PagingConfig(pageSize = 50, maxSize = 500)) { FakeInMemoryMediaPagingSource() }
264 
265         // Keep the flow processing out of the composable as that drastically cuts down on the
266         // flakiness of individual test runs.
267         flow = pager.flow.toMediaGridItemFromMedia().insertMonthSeparators()
268     }
269 
270     /** Initialize [EmbeddedState] instances */
271     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
272     private fun initEmbeddedStates() {
273         if (SdkLevel.isAtLeastU()) {
274             @Suppress("DEPRECATION")
275             (whenever(mockSurfaceControlViewHost.transferTouchGestureToHost()) { true })
276             testEmbeddedStateWithHostInCollapsedState =
277                 EmbeddedState(isExpanded = false, host = mockSurfaceControlViewHost)
278             testEmbeddedStateWithHostInExpandedState =
279                 EmbeddedState(isExpanded = true, host = mockSurfaceControlViewHost)
280         }
281     }
282 
283     /**
284      * Test wrapper around the mediaGrid which sets up the required collections, and applies a test
285      * tag before rendering the mediaGrid.
286      */
287     @Composable
288     private fun grid(
289         selection: SelectionImpl<Media>,
290         onItemClick: (MediaGridItem) -> Unit,
291         onItemLongPress: (MediaGridItem) -> Unit = {},
292         bannerContent: (@Composable () -> Unit)? = null,
293     ) {
294         val items = flow.collectAsLazyPagingItems()
295         val selected by selection.flow.collectAsStateWithLifecycle()
296 
297         mediaGrid(
298             items = items,
299             selection = selected,
300             onItemClick = onItemClick,
301             onItemLongPress = onItemLongPress,
302             bannerContent = bannerContent,
303             modifier = Modifier.testTag(MEDIA_GRID_TEST_TAG),
304         )
305     }
306 
307     /**
308      * A custom content item factory that renders the same text string for each item in the grid.
309      */
310     @Composable
311     private fun customContentItemFactory(item: MediaGridItem, onClick: ((MediaGridItem) -> Unit)?) {
312         Box(
313             modifier =
314                 // .clickable also merges the semantics of its descendants
315                 Modifier.testTag(CUSTOM_ITEM_TEST_TAG).clickable {
316                     if (item is MediaGridItem.MediaItem) {
317                         onClick?.invoke(item)
318                     }
319                 }
320         ) {
321             Text(CUSTOM_ITEM_FACTORY_TEXT)
322         }
323     }
324 
325     /** A custom content separator factory that renders the same text string for each separator. */
326     @Composable
327     private fun customContentSeparatorFactory() {
328         Box(
329             modifier =
330                 // Merge the semantics into the parent node to make it easy to asset and select
331                 // these nodes in the tree.
332                 Modifier.semantics(mergeDescendants = true) {}.testTag(CUSTOM_ITEM_SEPARATOR_TAG)
333         ) {
334             Text(CUSTOM_ITEM_SEPARATOR_TEXT)
335         }
336     }
337 
338     /** Ensures the MediaGrid loads media with the correct semantic information */
339     @Test
340     fun testMediaGridDisplaysMedia() = runTest {
341         val selection =
342             SelectionImpl<Media>(
343                 scope = backgroundScope,
344                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
345                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
346             )
347         composeTestRule.setContent {
348             CompositionLocalProvider(
349                 LocalPhotopickerConfiguration provides
350                     TestPhotopickerConfiguration.build {
351                         action("TEST_ACTION")
352                         intent(Intent("TEST_ACTION"))
353                     }
354             ) {
355                 PhotopickerTheme(
356                     isDarkTheme = false,
357                     config =
358                         TestPhotopickerConfiguration.build {
359                             action("TEST_ACTION")
360                             intent(Intent("TEST_ACTION"))
361                         },
362                 ) {
363                     grid(/* selection= */ selection, /* onItemClick= */ {})
364                 }
365             }
366         }
367 
368         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
369         mediaGrid.assertIsDisplayed()
370     }
371 
372     /** Ensures the MediaGrid shows any banner content that is provided. */
373     @Test
374     fun testMediaGridDisplaysBannerContent() = runTest {
375         val selection =
376             SelectionImpl<Media>(
377                 scope = backgroundScope,
378                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
379                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
380             )
381 
382         composeTestRule.setContent {
383             CompositionLocalProvider(
384                 LocalPhotopickerConfiguration provides
385                     TestPhotopickerConfiguration.build {
386                         action("TEST_ACTION")
387                         intent(Intent("TEST_ACTION"))
388                     }
389             ) {
390                 PhotopickerTheme(
391                     isDarkTheme = false,
392                     config =
393                         TestPhotopickerConfiguration.build {
394                             action("TEST_ACTION")
395                             intent(Intent("TEST_ACTION"))
396                         },
397                 ) {
398                     grid(
399                         selection = selection,
400                         onItemClick = {},
401                         onItemLongPress = {},
402                         bannerContent = {
403                             Text(
404                                 text = "bannerContent",
405                                 modifier = Modifier.testTag(BANNER_CONTENT_TEST_TAG),
406                             )
407                         },
408                     )
409                 }
410             }
411         }
412 
413         val mediaGrid = composeTestRule.onNode(hasTestTag(BANNER_CONTENT_TEST_TAG))
414         mediaGrid.assertIsDisplayed()
415     }
416 
417     /** Ensures the AlbumGrid loads media with the correct semantic information */
418     @Test
419     fun testAlbumGridDisplaysMedia() = runTest {
420         val selection =
421             SelectionImpl<Media>(
422                 scope = backgroundScope,
423                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
424                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
425             )
426 
427         // Modify the pager and flow to get data from the FakeInMemoryAlbumPagingSource.
428 
429         // Normally this would be created in the view model that owns the paged data.
430         val pagerForAlbums: Pager<MediaPageKey, Group.Album> =
431             Pager(PagingConfig(pageSize = 50, maxSize = 500)) { FakeInMemoryAlbumPagingSource() }
432 
433         // Keep the flow processing out of the composable as that drastically cuts down on the
434         // flakiness of individual test runs.
435         flow = pagerForAlbums.flow.toMediaGridItemFromAlbum()
436 
437         composeTestRule.setContent {
438             CompositionLocalProvider(
439                 LocalPhotopickerConfiguration provides
440                     TestPhotopickerConfiguration.build {
441                         action("TEST_ACTION")
442                         intent(Intent("TEST_ACTION"))
443                     }
444             ) {
445                 PhotopickerTheme(
446                     isDarkTheme = false,
447                     config =
448                         TestPhotopickerConfiguration.build {
449                             action("TEST_ACTION")
450                             intent(Intent("TEST_ACTION"))
451                         },
452                 ) {
453                     grid(/* selection= */ selection, /* onItemClick= */ {})
454                 }
455             }
456         }
457 
458         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
459         mediaGrid.assertIsDisplayed()
460     }
461 
462     /**
463      * Ensures the MediaGrid continues to load media as the grid is scrolled. This further ensures
464      * the grid, paging and glide integrations are correctly setup.
465      */
466     @Test
467     fun testMediaGridScroll() = runTest {
468         val selection =
469             SelectionImpl<Media>(
470                 scope = backgroundScope,
471                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
472                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
473             )
474 
475         composeTestRule.setContent {
476             CompositionLocalProvider(
477                 LocalPhotopickerConfiguration provides
478                     TestPhotopickerConfiguration.build {
479                         action("TEST_ACTION")
480                         intent(Intent("TEST_ACTION"))
481                     }
482             ) {
483                 PhotopickerTheme(
484                     isDarkTheme = false,
485                     config =
486                         TestPhotopickerConfiguration.build {
487                             action("TEST_ACTION")
488                             intent(Intent("TEST_ACTION"))
489                         },
490                 ) {
491                     grid(/* selection= */ selection, /* onItemClick= */ {})
492                 }
493             }
494         }
495 
496         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
497 
498         // Scroll the grid down by swiping up.
499         mediaGrid.performTouchInput { swipeUp() }
500         composeTestRule.waitForIdle()
501 
502         // Scroll the grid down by swiping up.
503         mediaGrid.performTouchInput { swipeUp() }
504         composeTestRule.waitForIdle()
505 
506         // Scroll the grid down by swiping up.
507         mediaGrid.performTouchInput { swipeUp() }
508         composeTestRule.waitForIdle()
509 
510         mediaGrid.assertIsDisplayed()
511     }
512 
513     /** Ensures that items have the correct semantic information before and after selection */
514     @Test
515     fun testMediaGridClickItemSingleSelect() {
516         runTest {
517             val selection =
518                 SelectionImpl<Media>(
519                     scope = backgroundScope,
520                     configuration =
521                         provideTestConfigurationFlow(
522                             scope = backgroundScope,
523                             defaultConfiguration =
524                                 TestPhotopickerConfiguration.build {
525                                     action("")
526                                     selectionLimit(1)
527                                 },
528                         ),
529                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
530                 )
531 
532             composeTestRule.setContent {
533                 CompositionLocalProvider(
534                     LocalPhotopickerConfiguration provides
535                         TestPhotopickerConfiguration.build {
536                             action("")
537                             selectionLimit(1)
538                         }
539                 ) {
540                     PhotopickerTheme(
541                         isDarkTheme = false,
542                         config =
543                             TestPhotopickerConfiguration.build {
544                                 action("")
545                                 selectionLimit(1)
546                             },
547                     ) {
548                         grid(
549                             /* selection= */ selection,
550                             /* onItemClick= */ { item ->
551                                 launch {
552                                     if (item is MediaGridItem.MediaItem)
553                                         selection.toggle(item.media)
554                                 }
555                             },
556                         )
557                     }
558                 }
559             }
560 
561             composeTestRule
562                 .onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
563                 .onChildren()
564                 // Remove the separators
565                 .filter(
566                     hasContentDescription(
567                         MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING,
568                         substring = true,
569                     )
570                 )
571                 .onFirst()
572                 .performClick()
573 
574             advanceTimeBy(100)
575             composeTestRule.waitForIdle()
576 
577             // Ensure the click handler correctly ran by checking the selection snapshot.
578             assertWithMessage("Expected selection to contain an item, but it did not.")
579                 .that(selection.snapshot().size)
580                 .isEqualTo(1)
581         }
582     }
583 
584     /** Ensures that items have the correct semantic information before and after selection */
585     @Test
586     fun testMediaGridClickItemMultiSelect() {
587         val resources = InstrumentationRegistry.getInstrumentation().getContext().getResources()
588         val selectedString = resources.getString(R.string.photopicker_item_selected)
589 
590         runTest {
591             val selection =
592                 SelectionImpl<Media>(
593                     scope = backgroundScope,
594                     configuration =
595                         provideTestConfigurationFlow(
596                             scope = backgroundScope,
597                             defaultConfiguration =
598                                 TestPhotopickerConfiguration.build {
599                                     action("")
600                                     selectionLimit(50)
601                                 },
602                         ),
603                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
604                 )
605 
606             composeTestRule.setContent {
607                 CompositionLocalProvider(
608                     LocalPhotopickerConfiguration provides
609                         TestPhotopickerConfiguration.build {
610                             action("")
611                             selectionLimit(50)
612                         }
613                 ) {
614                     PhotopickerTheme(
615                         isDarkTheme = false,
616                         config =
617                             TestPhotopickerConfiguration.build {
618                                 action("")
619                                 selectionLimit(50)
620                             },
621                     ) {
622                         grid(
623                             /* selection= */ selection,
624                             /* onItemClick= */ { item ->
625                                 launch {
626                                     if (item is MediaGridItem.MediaItem)
627                                         selection.toggle(item.media)
628                                 }
629                             },
630                         )
631                     }
632                 }
633             }
634 
635             composeTestRule
636                 .onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
637                 .onChildren()
638                 // Remove the separators
639                 .filter(
640                     hasContentDescription(
641                         MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING,
642                         substring = true,
643                     )
644                 )
645                 .onFirst()
646                 .performClick()
647 
648             advanceTimeBy(100)
649             composeTestRule.waitForIdle()
650 
651             // Ensure the click handler correctly ran by checking the selection snapshot.
652             assertWithMessage("Expected selection to contain an item, but it did not.")
653                 .that(selection.snapshot().size)
654                 .isEqualTo(1)
655 
656             // Ensure the selected semantics got applied to the selected node.
657             composeTestRule.waitUntilAtLeastOneExists(hasContentDescription(selectedString))
658         }
659     }
660 
661     /** Ensures that items have the correct semantic information before and after selection */
662     @Test
663     fun testMediaGridClickItemOrderedSelection() {
664         val photopickerConfiguration: PhotopickerConfiguration =
665             TestPhotopickerConfiguration.build {
666                 action(MediaStore.ACTION_PICK_IMAGES)
667                 intent(Intent(MediaStore.ACTION_PICK_IMAGES))
668                 selectionLimit(2)
669                 pickImagesInOrder(true)
670             }
671 
672         runTest {
673             val selection =
674                 SelectionImpl<Media>(
675                     scope = backgroundScope,
676                     configuration =
677                         provideTestConfigurationFlow(
678                             scope = backgroundScope,
679                             defaultConfiguration = photopickerConfiguration,
680                         ),
681                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
682                 )
683 
684             composeTestRule.setContent {
685                 CompositionLocalProvider(
686                     LocalPhotopickerConfiguration provides photopickerConfiguration
687                 ) {
688                     PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) {
689                         grid(
690                             /* selection= */ selection,
691                             /* onItemClick= */ { item ->
692                                 launch {
693                                     if (item is MediaGridItem.MediaItem)
694                                         selection.toggle(item.media)
695                                 }
696                             },
697                         )
698                     }
699                 }
700             }
701 
702             composeTestRule
703                 .onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
704                 .onChildren()
705                 // Remove the separators
706                 .filter(
707                     hasContentDescription(
708                         MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING,
709                         substring = true,
710                     )
711                 )
712                 .onFirst()
713                 .performClick()
714 
715             advanceTimeBy(100)
716             composeTestRule.waitForIdle()
717 
718             // Ensure the click handler correctly ran by checking the selection snapshot.
719             assertWithMessage("Expected selection to contain an item, but it did not.")
720                 .that(selection.snapshot().size)
721                 .isEqualTo(1)
722 
723             // Ensure the ordered selected semantics got applied to the selected node.
724             composeTestRule.waitUntilAtLeastOneExists(hasText("1"))
725         }
726     }
727 
728     /** Ensures that items have the correct semantic information before and after selection */
729     @Test
730     fun testMediaGridLongPressItem() {
731         runTest {
732             val selection =
733                 SelectionImpl<Media>(
734                     scope = backgroundScope,
735                     configuration = provideTestConfigurationFlow(scope = backgroundScope),
736                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
737                 )
738 
739             composeTestRule.setContent {
740                 CompositionLocalProvider(
741                     LocalPhotopickerConfiguration provides
742                         TestPhotopickerConfiguration.build {
743                             action("TEST_ACTION")
744                             intent(Intent("TEST_ACTION"))
745                         }
746                 ) {
747                     PhotopickerTheme(
748                         isDarkTheme = false,
749                         config =
750                             TestPhotopickerConfiguration.build {
751                                 action("TEST_ACTION")
752                                 intent(Intent("TEST_ACTION"))
753                             },
754                     ) {
755                         grid(
756                             /* selection= */ selection,
757                             /* onItemClick= */ {},
758                             /* onItemLongPress=*/ { item ->
759                                 launch {
760                                     if (item is MediaGridItem.MediaItem)
761                                         selection.toggle(item.media)
762                                 }
763                             },
764                         )
765                     }
766                 }
767             }
768 
769             composeTestRule
770                 .onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
771                 .onChildren()
772                 // Remove the separators
773                 .filter(
774                     hasContentDescription(
775                         MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING,
776                         substring = true,
777                     )
778                 )
779                 .onFirst()
780                 .performTouchInput { longClick() }
781 
782             advanceTimeBy(100)
783             composeTestRule.waitForIdle()
784 
785             // Ensure the click handler correctly ran by checking the selection snapshot.
786             assertWithMessage("Expected long press handler to have executed.")
787                 .that(selection.snapshot())
788                 .isNotEmpty()
789         }
790     }
791 
792     /** Ensures that Separators are correctly inserted into the MediaGrid. */
793     @Test
794     fun testMediaGridSeparator() {
795         // Provide a custom PagingData that puts Separators in specific positions to reduce
796         // test flakiness of having to scroll to find a separator.
797         val customData = PagingData.from(dataWithSeparators)
798         val dataFlow = flowOf(customData)
799 
800         runTest {
801             val selection =
802                 SelectionImpl<Media>(
803                     scope = backgroundScope,
804                     configuration = provideTestConfigurationFlow(scope = backgroundScope),
805                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
806                 )
807 
808             composeTestRule.setContent {
809                 CompositionLocalProvider(
810                     LocalPhotopickerConfiguration provides
811                         TestPhotopickerConfiguration.build {
812                             action("TEST_ACTION")
813                             intent(Intent("TEST_ACTION"))
814                         }
815                 ) {
816                     val items = dataFlow.collectAsLazyPagingItems()
817                     val selected by selection.flow.collectAsStateWithLifecycle()
818                     PhotopickerTheme(
819                         isDarkTheme = false,
820                         config =
821                             TestPhotopickerConfiguration.build {
822                                 action("TEST_ACTION")
823                                 intent(Intent("TEST_ACTION"))
824                             },
825                     ) {
826                         mediaGrid(items = items, selection = selected, onItemClick = {})
827                     }
828                 }
829             }
830 
831             composeTestRule
832                 .onAllNodes(
833                     hasContentDescription(
834                         value = MEDIA_ITEM_CONTENT_DESCRIPTION_SUBSTRING,
835                         substring = true,
836                     )
837                 )
838                 .assertCountEquals(3)
839             composeTestRule.onNode(hasText(FIRST_SEPARATOR_LABEL)).assertIsDisplayed()
840             composeTestRule.onNode(hasText(SECOND_SEPARATOR_LABEL)).assertIsDisplayed()
841         }
842     }
843 
844     /** Ensures that the grid uses a custom content item factory when it is provided */
845     @Test
846     fun testMediaGridCustomContentItemFactory() {
847         runTest {
848             val selection =
849                 SelectionImpl<Media>(
850                     scope = backgroundScope,
851                     configuration = provideTestConfigurationFlow(scope = backgroundScope),
852                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
853                 )
854 
855             composeTestRule.setContent {
856                 CompositionLocalProvider(
857                     LocalPhotopickerConfiguration provides
858                         TestPhotopickerConfiguration.build {
859                             action("TEST_ACTION")
860                             intent(Intent("TEST_ACTION"))
861                         }
862                 ) {
863                     val items = flow.collectAsLazyPagingItems()
864                     val selected by selection.flow.collectAsStateWithLifecycle()
865                     mediaGrid(
866                         items = items,
867                         selection = selected,
868                         onItemClick = {},
869                         onItemLongPress = {},
870                         contentItemFactory = { item, _, onClick, _, _ ->
871                             customContentItemFactory(item, onClick)
872                         },
873                     )
874                 }
875             }
876 
877             composeTestRule
878                 .onAllNodes(hasTestTag(CUSTOM_ITEM_TEST_TAG))
879                 .assertAll(hasText(CUSTOM_ITEM_FACTORY_TEXT))
880         }
881     }
882 
883     /** Ensures that the grid uses a custom content item factory when it is provided */
884     @Test
885     fun testMediaGridCustomContentSeparatorFactory() {
886         // Provide a custom PagingData that puts Separators in specific positions to reduce
887         // test flakiness of having to scroll to find a separator.
888         val customData = PagingData.from(dataWithSeparators)
889         val dataFlow = flowOf(customData)
890 
891         runTest {
892             val selection =
893                 SelectionImpl<Media>(
894                     scope = backgroundScope,
895                     configuration = provideTestConfigurationFlow(scope = backgroundScope),
896                     preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
897                 )
898 
899             composeTestRule.setContent {
900                 CompositionLocalProvider(
901                     LocalPhotopickerConfiguration provides
902                         TestPhotopickerConfiguration.build {
903                             action("TEST_ACTION")
904                             intent(Intent("TEST_ACTION"))
905                         }
906                 ) {
907                     val items = dataFlow.collectAsLazyPagingItems()
908                     val selected by selection.flow.collectAsStateWithLifecycle()
909                     mediaGrid(
910                         items = items,
911                         selection = selected,
912                         onItemClick = {},
913                         contentSeparatorFactory = { _ -> customContentSeparatorFactory() },
914                     )
915                 }
916             }
917 
918             composeTestRule
919                 .onAllNodes(hasTestTag(CUSTOM_ITEM_SEPARATOR_TAG))
920                 .assertAll(hasText(CUSTOM_ITEM_SEPARATOR_TEXT))
921         }
922     }
923 
924     /** Ensures that touches are transferring for embedded when swipe up in collapsed mode */
925     @Test
926     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
927     fun testTouchesAreTransferringToHostInEmbedded_CollapsedMode_SwipeUp() = runTest {
928         val selection =
929             SelectionImpl<Media>(
930                 scope = backgroundScope,
931                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
932                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
933             )
934 
935         composeTestRule.setContent {
936             CompositionLocalProvider(
937                 LocalPhotopickerConfiguration provides
938                     TestPhotopickerConfiguration.build {
939                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
940                     },
941                 LocalEmbeddedState provides testEmbeddedStateWithHostInCollapsedState,
942             ) {
943                 PhotopickerTheme(
944                     isDarkTheme = false,
945                     config =
946                         TestPhotopickerConfiguration.build {
947                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
948                         },
949                 ) {
950                     grid(/* selection= */ selection, /* onItemClick= */ {})
951                 }
952             }
953         }
954 
955         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
956 
957         mediaGrid.performTouchInput { swipeUp() }
958         composeTestRule.waitForIdle()
959         mediaGrid.assertIsDisplayed()
960         // Verify whether the method to transfer touch events is invoked during testing
961         @Suppress("DEPRECATION")
962         verify(mockSurfaceControlViewHost, atLeast(1)).transferTouchGestureToHost()
963     }
964 
965     /** Ensures that touches are transferring for embedded when swipe down in collapsed mode */
966     @Test
967     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
968     fun testTouchesAreTransferringToHostInEmbedded_CollapsedMode_SwipeDown() = runTest {
969         val selection =
970             SelectionImpl<Media>(
971                 scope = backgroundScope,
972                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
973                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
974             )
975 
976         composeTestRule.setContent {
977             CompositionLocalProvider(
978                 LocalPhotopickerConfiguration provides
979                     TestPhotopickerConfiguration.build {
980                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
981                     },
982                 LocalEmbeddedState provides testEmbeddedStateWithHostInCollapsedState,
983             ) {
984                 PhotopickerTheme(
985                     isDarkTheme = false,
986                     config =
987                         TestPhotopickerConfiguration.build {
988                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
989                         },
990                 ) {
991                     grid(/* selection= */ selection, /* onItemClick= */ {})
992                 }
993             }
994         }
995 
996         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
997 
998         mediaGrid.performTouchInput { swipeDown() }
999         composeTestRule.waitForIdle()
1000         mediaGrid.assertIsDisplayed()
1001         // Verify whether the method to transfer touch events is invoked during testing
1002         @Suppress("DEPRECATION")
1003         verify(mockSurfaceControlViewHost, atLeast(1)).transferTouchGestureToHost()
1004     }
1005 
1006     /** Ensures that clicks are not transferring for embedded in collapsed mode */
1007     @Test
1008     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1009     fun testTouchesAreNotTransferringToHostInEmbedded_CollapsedMode_Click() = runTest {
1010         val selection =
1011             SelectionImpl<Media>(
1012                 scope = backgroundScope,
1013                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
1014                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
1015             )
1016 
1017         composeTestRule.setContent {
1018             CompositionLocalProvider(
1019                 LocalPhotopickerConfiguration provides
1020                     TestPhotopickerConfiguration.build {
1021                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1022                     },
1023                 LocalEmbeddedState provides testEmbeddedStateWithHostInCollapsedState,
1024             ) {
1025                 PhotopickerTheme(
1026                     isDarkTheme = false,
1027                     config =
1028                         TestPhotopickerConfiguration.build {
1029                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1030                         },
1031                 ) {
1032                     grid(/* selection= */ selection, /* onItemClick= */ {})
1033                 }
1034             }
1035         }
1036 
1037         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
1038 
1039         mediaGrid.performTouchInput { click() }
1040         composeTestRule.waitForIdle()
1041         mediaGrid.assertIsDisplayed()
1042         // Verify whether the method to transfer touch events is not invoked during testing
1043         @Suppress("DEPRECATION")
1044         verify(mockSurfaceControlViewHost, never()).transferTouchGestureToHost()
1045     }
1046 
1047     /** Ensures that touches are not transferring for embedded when swipe up in Expanded mode */
1048     @Test
1049     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1050     fun testTouchesAreNotTransferringToHostInEmbedded_ExpandedMode_SwipeUP() = runTest {
1051         val selection =
1052             SelectionImpl<Media>(
1053                 scope = backgroundScope,
1054                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
1055                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
1056             )
1057 
1058         composeTestRule.setContent {
1059             CompositionLocalProvider(
1060                 LocalPhotopickerConfiguration provides
1061                     TestPhotopickerConfiguration.build {
1062                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1063                     },
1064                 LocalEmbeddedState provides testEmbeddedStateWithHostInExpandedState,
1065             ) {
1066                 PhotopickerTheme(
1067                     isDarkTheme = false,
1068                     config =
1069                         TestPhotopickerConfiguration.build {
1070                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1071                         },
1072                 ) {
1073                     grid(/* selection= */ selection, /* onItemClick= */ {})
1074                 }
1075             }
1076         }
1077 
1078         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
1079 
1080         mediaGrid.performTouchInput { swipeUp() }
1081         composeTestRule.waitForIdle()
1082         mediaGrid.assertIsDisplayed()
1083         // Verify whether the method to transfer touch events is not invoked during testing
1084         @Suppress("DEPRECATION")
1085         verify(mockSurfaceControlViewHost, never()).transferTouchGestureToHost()
1086     }
1087 
1088     /** Ensures that touches are transferring for embedded when swipe down in Expanded mode */
1089     @Test
1090     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1091     fun testTouchesAreTransferringToHostInEmbedded_ExpandedMode_SwipeDown() = runTest {
1092         val selection =
1093             SelectionImpl<Media>(
1094                 scope = backgroundScope,
1095                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
1096                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
1097             )
1098 
1099         composeTestRule.setContent {
1100             CompositionLocalProvider(
1101                 LocalPhotopickerConfiguration provides
1102                     TestPhotopickerConfiguration.build {
1103                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1104                     },
1105                 LocalEmbeddedState provides testEmbeddedStateWithHostInExpandedState,
1106             ) {
1107                 PhotopickerTheme(
1108                     isDarkTheme = false,
1109                     config =
1110                         TestPhotopickerConfiguration.build {
1111                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1112                         },
1113                 ) {
1114                     grid(/* selection= */ selection, /* onItemClick= */ {})
1115                 }
1116             }
1117         }
1118 
1119         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
1120 
1121         mediaGrid.performTouchInput { swipeDown() }
1122         composeTestRule.waitForIdle()
1123         mediaGrid.assertIsDisplayed()
1124         // Verify whether the method to transfer touch events is invoked during testing
1125         @Suppress("DEPRECATION")
1126         verify(mockSurfaceControlViewHost, atLeast(1)).transferTouchGestureToHost()
1127     }
1128 
1129     /** Ensures that clicks are not transferring for embedded in Expanded mode */
1130     @Test
1131     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1132     fun testTouchesAreNotTransferringToHostInEmbedded_ExpandedMode_Click() = runTest {
1133         val selection =
1134             SelectionImpl<Media>(
1135                 scope = backgroundScope,
1136                 configuration = provideTestConfigurationFlow(scope = backgroundScope),
1137                 preSelectedMedia = TestDataServiceImpl().preSelectionMediaData,
1138             )
1139 
1140         composeTestRule.setContent {
1141             CompositionLocalProvider(
1142                 LocalPhotopickerConfiguration provides
1143                     TestPhotopickerConfiguration.build {
1144                         runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1145                     },
1146                 LocalEmbeddedState provides testEmbeddedStateWithHostInExpandedState,
1147             ) {
1148                 PhotopickerTheme(
1149                     isDarkTheme = false,
1150                     config =
1151                         TestPhotopickerConfiguration.build {
1152                             runtimeEnv(PhotopickerRuntimeEnv.EMBEDDED)
1153                         },
1154                 ) {
1155                     grid(/* selection= */ selection, /* onItemClick= */ {})
1156                 }
1157             }
1158         }
1159 
1160         val mediaGrid = composeTestRule.onNode(hasTestTag(MEDIA_GRID_TEST_TAG))
1161 
1162         mediaGrid.performTouchInput { click() }
1163         composeTestRule.waitForIdle()
1164         mediaGrid.assertIsDisplayed()
1165         // Verify whether the method to transfer touch events is not invoked during testing
1166         @Suppress("DEPRECATION")
1167         verify(mockSurfaceControlViewHost, never()).transferTouchGestureToHost()
1168     }
1169 }
1170