1 /* 2 * Copyright (C) 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.features.selectionbar 18 19 import android.content.ContentResolver 20 import android.content.Context 21 import android.content.Intent 22 import android.content.pm.PackageManager 23 import android.net.Uri 24 import android.os.UserManager 25 import android.provider.MediaStore 26 import android.test.mock.MockContentResolver 27 import androidx.compose.runtime.CompositionLocalProvider 28 import androidx.compose.ui.Modifier 29 import androidx.compose.ui.platform.testTag 30 import androidx.compose.ui.test.ExperimentalTestApi 31 import androidx.compose.ui.test.assert 32 import androidx.compose.ui.test.assertIsDisplayed 33 import androidx.compose.ui.test.hasClickAction 34 import androidx.compose.ui.test.hasContentDescription 35 import androidx.compose.ui.test.hasTestTag 36 import androidx.compose.ui.test.hasText 37 import androidx.compose.ui.test.junit4.createAndroidComposeRule 38 import androidx.compose.ui.test.performClick 39 import com.android.photopicker.R 40 import com.android.photopicker.core.ActivityModule 41 import com.android.photopicker.core.ApplicationModule 42 import com.android.photopicker.core.ApplicationOwned 43 import com.android.photopicker.core.Background 44 import com.android.photopicker.core.ConcurrencyModule 45 import com.android.photopicker.core.EmbeddedServiceModule 46 import com.android.photopicker.core.Main 47 import com.android.photopicker.core.configuration.ConfigurationManager 48 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration 49 import com.android.photopicker.core.configuration.PhotopickerConfiguration 50 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv 51 import com.android.photopicker.core.configuration.TestPhotopickerConfiguration 52 import com.android.photopicker.core.configuration.provideTestConfigurationFlow 53 import com.android.photopicker.core.events.Events 54 import com.android.photopicker.core.events.LocalEvents 55 import com.android.photopicker.core.events.generatePickerSessionId 56 import com.android.photopicker.core.features.FeatureManager 57 import com.android.photopicker.core.features.LocalFeatureManager 58 import com.android.photopicker.core.features.LocationParams 59 import com.android.photopicker.core.glide.GlideTestRule 60 import com.android.photopicker.core.navigation.LocalNavController 61 import com.android.photopicker.core.selection.LocalSelection 62 import com.android.photopicker.core.selection.Selection 63 import com.android.photopicker.core.theme.PhotopickerTheme 64 import com.android.photopicker.data.TestPrefetchDataService 65 import com.android.photopicker.data.model.Media 66 import com.android.photopicker.data.model.MediaSource 67 import com.android.photopicker.features.PhotopickerFeatureBaseTest 68 import com.android.photopicker.features.simpleuifeature.SimpleUiFeature 69 import com.android.photopicker.inject.PhotopickerTestModule 70 import com.android.photopicker.tests.HiltTestActivity 71 import com.android.photopicker.util.test.whenever 72 import com.google.common.truth.Truth.assertWithMessage 73 import dagger.Lazy 74 import dagger.Module 75 import dagger.hilt.InstallIn 76 import dagger.hilt.android.testing.BindValue 77 import dagger.hilt.android.testing.HiltAndroidRule 78 import dagger.hilt.android.testing.HiltAndroidTest 79 import dagger.hilt.android.testing.UninstallModules 80 import dagger.hilt.components.SingletonComponent 81 import javax.inject.Inject 82 import kotlinx.coroutines.CompletableDeferred 83 import kotlinx.coroutines.CoroutineDispatcher 84 import kotlinx.coroutines.CoroutineScope 85 import kotlinx.coroutines.ExperimentalCoroutinesApi 86 import kotlinx.coroutines.test.StandardTestDispatcher 87 import kotlinx.coroutines.test.TestScope 88 import kotlinx.coroutines.test.advanceTimeBy 89 import kotlinx.coroutines.test.runTest 90 import org.junit.Before 91 import org.junit.Rule 92 import org.junit.Test 93 import org.mockito.Mock 94 import org.mockito.MockitoAnnotations 95 96 @UninstallModules( 97 ActivityModule::class, 98 ApplicationModule::class, 99 ConcurrencyModule::class, 100 EmbeddedServiceModule::class, 101 ) 102 @HiltAndroidTest 103 @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class) 104 class SelectionBarFeatureTest : PhotopickerFeatureBaseTest() { 105 106 /* Hilt's rule needs to come first to ensure the DI container is setup for the test. */ 107 @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) 108 @get:Rule(order = 1) 109 val composeTestRule = createAndroidComposeRule(activityClass = HiltTestActivity::class.java) 110 @get:Rule(order = 2) val glideRule = GlideTestRule() 111 112 /* Setup dependencies for the UninstallModules for the test class. */ 113 @Module @InstallIn(SingletonComponent::class) class TestModule : PhotopickerTestModule() 114 115 val testDispatcher = StandardTestDispatcher() 116 val sessionId = generatePickerSessionId() 117 118 /* Overrides for ActivityModule */ 119 val testScope: TestScope = TestScope(testDispatcher) 120 @BindValue @Main val mainScope: CoroutineScope = testScope 121 @BindValue @Background var testBackgroundScope: CoroutineScope = testScope.backgroundScope 122 123 /* Overrides for the ConcurrencyModule */ 124 @BindValue @Main val mainDispatcher: CoroutineDispatcher = testDispatcher 125 @BindValue @Background val backgroundDispatcher: CoroutineDispatcher = testDispatcher 126 127 @Mock lateinit var mockUserManager: UserManager 128 @Mock lateinit var mockPackageManager: PackageManager 129 @BindValue @ApplicationOwned lateinit var mockContentResolver: ContentResolver 130 131 @Inject lateinit var mockContext: Context 132 @Inject lateinit var selection: Lazy<Selection<Media>> 133 @Inject lateinit var featureManager: Lazy<FeatureManager> 134 @Inject override lateinit var configurationManager: Lazy<ConfigurationManager> 135 @Inject lateinit var events: Lazy<Events> 136 137 val TEST_TAG_SELECTION_BAR = "selection_bar" 138 val MEDIA_ITEM = 139 Media.Image( 140 mediaId = "1", 141 pickerId = 1L, 142 authority = "a", 143 mediaSource = MediaSource.LOCAL, 144 mediaUri = 145 Uri.EMPTY.buildUpon() <lambda>null146 .apply { 147 scheme("content") 148 authority("media") 149 path("picker") 150 path("a") 151 path("1") 152 } 153 .build(), 154 glideLoadableUri = 155 Uri.EMPTY.buildUpon() <lambda>null156 .apply { 157 scheme("content") 158 authority("a") 159 path("1") 160 } 161 .build(), 162 dateTakenMillisLong = 123456789L, 163 sizeInBytes = 1000L, 164 mimeType = "image/png", 165 standardMimeTypeExtension = 1, 166 ) 167 168 @Before setupnull169 fun setup() { 170 MockitoAnnotations.initMocks(this) 171 hiltRule.inject() 172 173 val testIntent = 174 Intent(MediaStore.ACTION_PICK_IMAGES).apply { 175 putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, 5) 176 } 177 configurationManager.get().setIntent(testIntent) 178 179 // Stub for MockContentResolver constructor 180 whenever(mockContext.getApplicationInfo()) { getTestableContext().getApplicationInfo() } 181 mockContentResolver = MockContentResolver(mockContext) 182 183 setupTestForUserMonitor( 184 mockContext, 185 mockUserManager, 186 mockContentResolver, 187 mockPackageManager, 188 ) 189 } 190 191 @Test testSelectionBarIsEnabledWithSelectionLimitInActivityModenull192 fun testSelectionBarIsEnabledWithSelectionLimitInActivityMode() { 193 val configOne = 194 PhotopickerConfiguration( 195 action = "TEST_ACTION", 196 selectionLimit = 5, 197 sessionId = sessionId, 198 ) 199 assertWithMessage("SelectionBarFeature is not always enabled for TEST_ACTION") 200 .that(SelectionBarFeature.Registration.isEnabled(configOne)) 201 .isEqualTo(true) 202 203 val configTwo = 204 PhotopickerConfiguration( 205 action = MediaStore.ACTION_PICK_IMAGES, 206 selectionLimit = 5, 207 sessionId = sessionId, 208 ) 209 assertWithMessage("SelectionBarFeature is not always enabled") 210 .that(SelectionBarFeature.Registration.isEnabled(configTwo)) 211 .isEqualTo(true) 212 213 val configThree = 214 PhotopickerConfiguration( 215 action = Intent.ACTION_GET_CONTENT, 216 selectionLimit = 5, 217 sessionId = sessionId, 218 ) 219 assertWithMessage("SelectionBarFeature is not always enabled") 220 .that(SelectionBarFeature.Registration.isEnabled(configThree)) 221 .isEqualTo(true) 222 } 223 224 @Test testSelectionBarNotEnabledForSingleSelectInActivityModenull225 fun testSelectionBarNotEnabledForSingleSelectInActivityMode() { 226 val configOne = PhotopickerConfiguration(action = "TEST_ACTION", sessionId = sessionId) 227 assertWithMessage("SelectionBarFeature is not always enabled for TEST_ACTION") 228 .that(SelectionBarFeature.Registration.isEnabled(configOne)) 229 .isEqualTo(false) 230 231 val configTwo = 232 PhotopickerConfiguration(action = MediaStore.ACTION_PICK_IMAGES, sessionId = sessionId) 233 assertWithMessage("SelectionBarFeature is not always enabled") 234 .that(SelectionBarFeature.Registration.isEnabled(configTwo)) 235 .isEqualTo(false) 236 237 val configThree = 238 PhotopickerConfiguration(action = Intent.ACTION_GET_CONTENT, sessionId = sessionId) 239 assertWithMessage("SelectionBarFeature is not always enabled") 240 .that(SelectionBarFeature.Registration.isEnabled(configThree)) 241 .isEqualTo(false) 242 } 243 244 @Test testSelectionBarIsAlwaysEnabledInEmbeddedModenull245 fun testSelectionBarIsAlwaysEnabledInEmbeddedMode() { 246 val configOne = 247 PhotopickerConfiguration( 248 action = "", 249 runtimeEnv = PhotopickerRuntimeEnv.EMBEDDED, 250 selectionLimit = 1, 251 sessionId = sessionId, 252 ) 253 assertWithMessage("SelectionBarFeature not always enabled for EMBEDDED mode") 254 .that(SelectionBarFeature.Registration.isEnabled(configOne)) 255 .isEqualTo(true) 256 257 val configTwo = 258 PhotopickerConfiguration( 259 action = "", 260 runtimeEnv = PhotopickerRuntimeEnv.EMBEDDED, 261 selectionLimit = 20, 262 sessionId = sessionId, 263 ) 264 assertWithMessage("SelectionBarFeature not always enabled for EMBEDDED mode") 265 .that(SelectionBarFeature.Registration.isEnabled(configTwo)) 266 .isEqualTo(true) 267 } 268 269 @Test testSelectionBarIsShownnull270 fun testSelectionBarIsShown() { 271 testScope.runTest { 272 val photopickerConfiguration: PhotopickerConfiguration = 273 TestPhotopickerConfiguration.build { 274 action("TEST_ACTION") 275 intent(Intent("TEST_ACTION")) 276 } 277 composeTestRule.setContent { 278 CompositionLocalProvider( 279 LocalFeatureManager provides featureManager.get(), 280 LocalSelection provides selection.get(), 281 LocalEvents provides events.get(), 282 LocalNavController provides createNavController(), 283 LocalPhotopickerConfiguration provides photopickerConfiguration, 284 ) { 285 PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) { 286 SelectionBar( 287 modifier = Modifier.testTag(TEST_TAG_SELECTION_BAR), 288 params = LocationParams.None, 289 ) 290 } 291 } 292 } 293 composeTestRule.onNode(hasTestTag(TEST_TAG_SELECTION_BAR)).assertDoesNotExist() 294 selection.get().add(MEDIA_ITEM) 295 advanceTimeBy(100) 296 composeTestRule.waitForIdle() 297 298 composeTestRule 299 .onNode(hasTestTag(TEST_TAG_SELECTION_BAR)) 300 .assertExists() 301 .assertIsDisplayed() 302 } 303 } 304 305 @Test testSelectionBarIsAlwaysShownForGrantsAwareSelectionnull306 fun testSelectionBarIsAlwaysShownForGrantsAwareSelection() { 307 testScope.runTest { 308 val photopickerConfiguration: PhotopickerConfiguration = 309 TestPhotopickerConfiguration.build { 310 action(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP) 311 intent(Intent(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP)) 312 callingPackage("com.example.test") 313 callingPackageUid(1234) 314 callingPackageLabel("test_app") 315 } 316 composeTestRule.setContent { 317 CompositionLocalProvider( 318 LocalFeatureManager provides featureManager.get(), 319 LocalSelection provides selection.get(), 320 LocalEvents provides events.get(), 321 LocalNavController provides createNavController(), 322 LocalPhotopickerConfiguration provides photopickerConfiguration, 323 ) { 324 PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) { 325 SelectionBar( 326 modifier = Modifier.testTag(TEST_TAG_SELECTION_BAR), 327 params = LocationParams.None, 328 ) 329 } 330 } 331 } 332 composeTestRule.waitForIdle() 333 334 // verify that the selection bar is displayed 335 composeTestRule 336 .onNode(hasTestTag(TEST_TAG_SELECTION_BAR)) 337 .assertExists() 338 .assertIsDisplayed() 339 } 340 } 341 342 @Test testSelectionBarShowsSecondaryActionnull343 fun testSelectionBarShowsSecondaryAction() { 344 val testFeatureRegistrations = 345 setOf(SelectionBarFeature.Registration, SimpleUiFeature.Registration) 346 347 testScope.runTest { 348 val testFeatureManager = 349 FeatureManager( 350 provideTestConfigurationFlow(scope = this.backgroundScope), 351 this.backgroundScope, 352 TestPrefetchDataService(), 353 testFeatureRegistrations, 354 ) 355 val photopickerConfiguration: PhotopickerConfiguration = 356 TestPhotopickerConfiguration.build { 357 action("TEST_ACTION") 358 intent(Intent("TEST_ACTION")) 359 } 360 composeTestRule.setContent { 361 CompositionLocalProvider( 362 LocalFeatureManager provides testFeatureManager, 363 LocalSelection provides selection.get(), 364 LocalEvents provides events.get(), 365 LocalPhotopickerConfiguration provides photopickerConfiguration, 366 ) { 367 PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) { 368 SelectionBar( 369 modifier = Modifier.testTag(TEST_TAG_SELECTION_BAR), 370 params = LocationParams.None, 371 ) 372 } 373 } 374 } 375 376 composeTestRule.onNode(hasText(SimpleUiFeature.BUTTON_LABEL)).assertDoesNotExist() 377 selection.get().add(MEDIA_ITEM) 378 advanceTimeBy(100) 379 composeTestRule.waitForIdle() 380 381 composeTestRule 382 .onNode(hasText(SimpleUiFeature.BUTTON_LABEL)) 383 .assertExists() 384 .assertIsDisplayed() 385 } 386 } 387 388 @Test testSelectionBarPrimaryActionnull389 fun testSelectionBarPrimaryAction() { 390 391 testScope.runTest { 392 val clicked = CompletableDeferred<Boolean>() 393 val photopickerConfiguration: PhotopickerConfiguration = 394 TestPhotopickerConfiguration.build { 395 action("TEST_ACTION") 396 intent(Intent("TEST_ACTION")) 397 } 398 composeTestRule.setContent { 399 CompositionLocalProvider( 400 LocalFeatureManager provides featureManager.get(), 401 LocalSelection provides selection.get(), 402 LocalEvents provides events.get(), 403 LocalNavController provides createNavController(), 404 LocalPhotopickerConfiguration provides photopickerConfiguration, 405 ) { 406 PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) { 407 SelectionBar( 408 modifier = Modifier.testTag(TEST_TAG_SELECTION_BAR), 409 params = LocationParams.WithClickAction { clicked.complete(true) }, 410 ) 411 } 412 } 413 } 414 415 // Populate selection with an item, and wait for animations to complete. 416 selection.get().add(MEDIA_ITEM) 417 advanceTimeBy(100) 418 composeTestRule.waitForIdle() 419 420 val resources = getTestableContext().getResources() 421 val buttonLabel = resources.getString(R.string.photopicker_done_button_label) 422 423 // Find the button, ensure it has a registered click handler, is displayed. 424 composeTestRule 425 .onNode(hasText(buttonLabel)) 426 .assertIsDisplayed() 427 .assert(hasClickAction()) 428 .performClick() 429 430 val wasClicked = clicked.await() 431 assertWithMessage("Expected primary action to invoke click handler") 432 .that(wasClicked) 433 .isTrue() 434 } 435 } 436 437 @Test testSelectionBarClearSelectionnull438 fun testSelectionBarClearSelection() { 439 440 testScope.runTest { 441 val photopickerConfiguration: PhotopickerConfiguration = 442 TestPhotopickerConfiguration.build { 443 action("TEST_ACTION") 444 intent(Intent("TEST_ACTION")) 445 } 446 447 composeTestRule.setContent { 448 CompositionLocalProvider( 449 LocalFeatureManager provides featureManager.get(), 450 LocalSelection provides selection.get(), 451 LocalEvents provides events.get(), 452 LocalNavController provides createNavController(), 453 LocalPhotopickerConfiguration provides photopickerConfiguration, 454 ) { 455 PhotopickerTheme(isDarkTheme = false, config = photopickerConfiguration) { 456 SelectionBar( 457 modifier = Modifier.testTag(TEST_TAG_SELECTION_BAR), 458 params = LocationParams.None, 459 ) 460 } 461 } 462 } 463 464 // Populate selection with an item, and wait for animations to complete. 465 selection.get().add(MEDIA_ITEM) 466 467 assertWithMessage("Expected selection to contain an item.") 468 .that(selection.get().snapshot().size) 469 .isEqualTo(1) 470 471 advanceTimeBy(100) 472 composeTestRule.waitForIdle() 473 474 val resources = getTestableContext().getResources() 475 val clearDescription = 476 resources.getString(R.string.photopicker_clear_selection_button_description) 477 478 // Find the button, ensure it has a registered click handler, is displayed. 479 composeTestRule 480 .onNode(hasContentDescription(clearDescription)) 481 .assertIsDisplayed() 482 .assert(hasClickAction()) 483 .performClick() 484 485 advanceTimeBy(100) 486 487 assertWithMessage("Expected selection to be cleared.") 488 .that(selection.get().snapshot()) 489 .isEmpty() 490 } 491 } 492 } 493