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