1 /* <lambda>null2 * Copyright (C) 2022 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 18 package com.android.customization.model.picker.quickaffordance.ui.viewmodel 19 20 import android.content.Context 21 import android.content.Intent 22 import androidx.test.core.app.ApplicationProvider 23 import androidx.test.filters.SmallTest 24 import com.android.customization.module.logging.TestThemesUserEventLogger 25 import com.android.customization.picker.quickaffordance.data.repository.KeyguardQuickAffordancePickerRepository 26 import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor 27 import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordanceSnapshotRestorer 28 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel 29 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel 30 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel 31 import com.android.systemui.shared.customization.data.content.CustomizationProviderClient 32 import com.android.systemui.shared.customization.data.content.FakeCustomizationProviderClient 33 import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots 34 import com.android.themepicker.R 35 import com.android.wallpaper.module.InjectorProvider 36 import com.android.wallpaper.module.NetworkStatusNotifier 37 import com.android.wallpaper.module.PartnerProvider 38 import com.android.wallpaper.module.WallpaperPreferences 39 import com.android.wallpaper.network.Requester 40 import com.android.wallpaper.picker.category.wrapper.WallpaperCategoryWrapper 41 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon 42 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text 43 import com.android.wallpaper.picker.customization.data.repository.WallpaperRepository 44 import com.android.wallpaper.picker.customization.domain.interactor.WallpaperInteractor 45 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel 46 import com.android.wallpaper.testing.FakeWallpaperClient 47 import com.android.wallpaper.testing.TestCurrentWallpaperInfoFactory 48 import com.android.wallpaper.testing.TestInjector 49 import com.android.wallpaper.testing.TestPackageStatusNotifier 50 import com.android.wallpaper.testing.TestWallpaperPreferences 51 import com.android.wallpaper.testing.collectLastValue 52 import com.android.wallpaper.util.DisplayUtils 53 import com.android.wallpaper.util.DisplaysProvider 54 import com.google.common.truth.Truth.assertThat 55 import com.google.common.truth.Truth.assertWithMessage 56 import kotlinx.coroutines.Dispatchers 57 import kotlinx.coroutines.ExperimentalCoroutinesApi 58 import kotlinx.coroutines.test.StandardTestDispatcher 59 import kotlinx.coroutines.test.TestScope 60 import kotlinx.coroutines.test.resetMain 61 import kotlinx.coroutines.test.runTest 62 import kotlinx.coroutines.test.setMain 63 import org.junit.After 64 import org.junit.Before 65 import org.junit.Test 66 import org.junit.runner.RunWith 67 import org.mockito.Mockito.mock 68 import org.robolectric.RobolectricTestRunner 69 70 @OptIn(ExperimentalCoroutinesApi::class) 71 @SmallTest 72 @RunWith(RobolectricTestRunner::class) 73 class KeyguardQuickAffordancePickerViewModelTest { 74 75 private val logger = TestThemesUserEventLogger() 76 77 private lateinit var underTest: KeyguardQuickAffordancePickerViewModel 78 79 private lateinit var context: Context 80 private lateinit var testScope: TestScope 81 private lateinit var client: FakeCustomizationProviderClient 82 private lateinit var quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor 83 private lateinit var wallpaperInteractor: WallpaperInteractor 84 private lateinit var testPackageStatusNotifier: TestPackageStatusNotifier 85 86 @Before 87 fun setUp() { 88 context = ApplicationProvider.getApplicationContext() 89 val testDispatcher = StandardTestDispatcher() 90 testScope = TestScope(testDispatcher) 91 Dispatchers.setMain(testDispatcher) 92 client = FakeCustomizationProviderClient() 93 94 quickAffordanceInteractor = 95 KeyguardQuickAffordancePickerInteractor( 96 repository = 97 KeyguardQuickAffordancePickerRepository( 98 client = client, 99 mainScope = testScope.backgroundScope, 100 ), 101 client = client, 102 snapshotRestorer = KeyguardQuickAffordanceSnapshotRestorer(client), 103 ) 104 wallpaperInteractor = 105 WallpaperInteractor( 106 repository = 107 WallpaperRepository( 108 scope = testScope.backgroundScope, 109 client = FakeWallpaperClient(), 110 wallpaperPreferences = TestWallpaperPreferences(), 111 backgroundDispatcher = testDispatcher, 112 ) 113 ) 114 testPackageStatusNotifier = TestPackageStatusNotifier() 115 InjectorProvider.setInjector( 116 TestInjector( 117 logger, 118 DisplayUtils(context, mock(DisplaysProvider::class.java)), 119 mock(Requester::class.java), 120 mock(NetworkStatusNotifier::class.java), 121 mock(PartnerProvider::class.java), 122 FakeWallpaperClient(), 123 wallpaperInteractor, 124 mock(WallpaperPreferences::class.java), 125 mock(WallpaperCategoryWrapper::class.java), 126 testPackageStatusNotifier, 127 ) 128 ) 129 underTest = 130 KeyguardQuickAffordancePickerViewModel.Factory( 131 context = context, 132 quickAffordanceInteractor = quickAffordanceInteractor, 133 wallpaperInteractor = wallpaperInteractor, 134 wallpaperInfoFactory = TestCurrentWallpaperInfoFactory(context), 135 logger = logger, 136 ) 137 .create(KeyguardQuickAffordancePickerViewModel::class.java) 138 } 139 140 @After 141 fun tearDown() { 142 Dispatchers.resetMain() 143 } 144 145 @Test 146 fun `Select an affordance for each side`() = 147 testScope.runTest { 148 val slots = collectLastValue(underTest.slots) 149 val quickAffordances = collectLastValue(underTest.quickAffordances) 150 151 // Initially, the first slot is selected with the "none" affordance selected. 152 assertPickerUiState( 153 slots = slots(), 154 affordances = quickAffordances(), 155 selectedSlotText = "Left button", 156 selectedAffordanceText = "None", 157 ) 158 assertPreviewUiState( 159 slots = slots(), 160 expectedAffordanceNameBySlotId = 161 mapOf( 162 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to null, 163 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to null, 164 ), 165 ) 166 167 // Select "affordance 1" for the first slot. 168 selectAffordance(quickAffordances, 1) 169 assertPickerUiState( 170 slots = slots(), 171 affordances = quickAffordances(), 172 selectedSlotText = "Left button", 173 selectedAffordanceText = FakeCustomizationProviderClient.AFFORDANCE_1, 174 ) 175 assertPreviewUiState( 176 slots = slots(), 177 expectedAffordanceNameBySlotId = 178 mapOf( 179 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to 180 FakeCustomizationProviderClient.AFFORDANCE_1, 181 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to null, 182 ), 183 ) 184 185 // Select an affordance for the second slot. 186 // First, switch to the second slot: 187 slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke() 188 // Second, select the "affordance 3" affordance: 189 selectAffordance(quickAffordances, 3) 190 assertPickerUiState( 191 slots = slots(), 192 affordances = quickAffordances(), 193 selectedSlotText = "Right button", 194 selectedAffordanceText = FakeCustomizationProviderClient.AFFORDANCE_3, 195 ) 196 assertPreviewUiState( 197 slots = slots(), 198 expectedAffordanceNameBySlotId = 199 mapOf( 200 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to 201 FakeCustomizationProviderClient.AFFORDANCE_1, 202 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to 203 FakeCustomizationProviderClient.AFFORDANCE_3, 204 ), 205 ) 206 207 // Select a different affordance for the second slot. 208 selectAffordance(quickAffordances, 2) 209 assertPickerUiState( 210 slots = slots(), 211 affordances = quickAffordances(), 212 selectedSlotText = "Right button", 213 selectedAffordanceText = FakeCustomizationProviderClient.AFFORDANCE_2, 214 ) 215 assertPreviewUiState( 216 slots = slots(), 217 expectedAffordanceNameBySlotId = 218 mapOf( 219 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to 220 FakeCustomizationProviderClient.AFFORDANCE_1, 221 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to 222 FakeCustomizationProviderClient.AFFORDANCE_2, 223 ), 224 ) 225 } 226 227 @Test 228 fun `Unselect - AKA selecting the none affordance - on one side`() = 229 testScope.runTest { 230 val slots = collectLastValue(underTest.slots) 231 val quickAffordances = collectLastValue(underTest.quickAffordances) 232 233 // Select "affordance 1" for the first slot. 234 selectAffordance(quickAffordances, 1) 235 // Select an affordance for the second slot. 236 // First, switch to the second slot: 237 slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke() 238 // Second, select the "affordance 3" affordance: 239 selectAffordance(quickAffordances, 3) 240 241 // Switch back to the first slot: 242 slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START)?.onClicked?.invoke() 243 // Select the "none" affordance, which is always in position 0: 244 selectAffordance(quickAffordances, 0) 245 246 assertPickerUiState( 247 slots = slots(), 248 affordances = quickAffordances(), 249 selectedSlotText = "Left button", 250 selectedAffordanceText = "None", 251 ) 252 assertPreviewUiState( 253 slots = slots(), 254 expectedAffordanceNameBySlotId = 255 mapOf( 256 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START to null, 257 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END to 258 FakeCustomizationProviderClient.AFFORDANCE_3, 259 ), 260 ) 261 } 262 263 @Test 264 fun `Show enablement dialog when selecting a disabled affordance`() = 265 testScope.runTest { 266 val slots = collectLastValue(underTest.slots) 267 val quickAffordances = collectLastValue(underTest.quickAffordances) 268 val dialog = collectLastValue(underTest.dialog) 269 val activityStartRequest = collectLastValue(underTest.activityStartRequests) 270 271 val enablementExplanation = "enablementExplanation" 272 val enablementActionText = "enablementActionText" 273 val packageName = "packageName" 274 val action = "action" 275 val enablementActionIntent = Intent(action).apply { `package` = packageName } 276 // Lets add a disabled affordance to the picker: 277 val affordanceIndex = 278 client.addAffordance( 279 CustomizationProviderClient.Affordance( 280 id = "disabled", 281 name = "disabled", 282 iconResourceId = 1, 283 isEnabled = false, 284 enablementExplanation = enablementExplanation, 285 enablementActionText = enablementActionText, 286 enablementActionIntent = enablementActionIntent, 287 ) 288 ) 289 290 // Lets try to select that disabled affordance: 291 selectAffordance(quickAffordances, affordanceIndex + 1) 292 293 // We expect there to be a dialog that should be shown: 294 assertThat(dialog()?.icon) 295 .isEqualTo(Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null)) 296 assertThat(dialog()?.headline) 297 .isEqualTo(Text.Resource(R.string.keyguard_affordance_enablement_dialog_headline)) 298 assertThat(dialog()?.message).isEqualTo(Text.Loaded(enablementExplanation)) 299 assertThat(dialog()?.buttons?.size).isEqualTo(2) 300 assertThat(dialog()?.buttons?.first()?.text).isEqualTo(Text.Resource(R.string.cancel)) 301 assertThat(dialog()?.buttons?.get(1)?.text).isEqualTo(Text.Loaded(enablementActionText)) 302 303 // When the button is clicked, we expect an intent of the given enablement action 304 // component name to be emitted. 305 dialog()?.buttons?.get(1)?.onClicked?.invoke() 306 assertThat(activityStartRequest()?.`package`).isEqualTo(packageName) 307 assertThat(activityStartRequest()?.action).isEqualTo(action) 308 309 // Once we report that the activity was started, the activity start request should be 310 // nullified. 311 underTest.onActivityStarted() 312 assertThat(activityStartRequest()).isNull() 313 314 // Once we report that the dialog has been dismissed by the user, we expect there to be 315 // no dialog to be shown: 316 underTest.onDialogDismissed() 317 assertThat(dialog()).isNull() 318 } 319 320 @Test 321 fun `Start settings activity when long-pressing an affordance`() = 322 testScope.runTest { 323 val quickAffordances = collectLastValue(underTest.quickAffordances) 324 val activityStartRequest = collectLastValue(underTest.activityStartRequests) 325 326 // Lets add a configurable affordance to the picker: 327 val configureIntent = Intent("some.action") 328 val affordanceIndex = 329 client.addAffordance( 330 CustomizationProviderClient.Affordance( 331 id = "affordance", 332 name = "affordance", 333 iconResourceId = 1, 334 isEnabled = true, 335 configureIntent = configureIntent, 336 ) 337 ) 338 339 // Lets try to long-click the affordance: 340 quickAffordances()?.get(affordanceIndex + 1)?.onLongClicked?.invoke() 341 342 assertThat(activityStartRequest()).isEqualTo(configureIntent) 343 // Once we report that the activity was started, the activity start request should be 344 // nullified. 345 underTest.onActivityStarted() 346 assertThat(activityStartRequest()).isNull() 347 } 348 349 @Test 350 fun `summary - affordance selected in both bottom-start and bottom-end`() = 351 testScope.runTest { 352 val slots = collectLastValue(underTest.slots) 353 val quickAffordances = collectLastValue(underTest.quickAffordances) 354 val summary = collectLastValue(underTest.summary) 355 356 // Select "affordance 1" for the first slot. 357 selectAffordance(quickAffordances, 1) 358 // Select an affordance for the second slot. 359 // First, switch to the second slot: 360 slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke() 361 // Second, select the "affordance 3" affordance: 362 selectAffordance(quickAffordances, 3) 363 364 assertThat(summary()) 365 .isEqualTo( 366 KeyguardQuickAffordanceSummaryViewModel( 367 description = 368 Text.Loaded( 369 "${FakeCustomizationProviderClient.AFFORDANCE_1}," + 370 " ${FakeCustomizationProviderClient.AFFORDANCE_3}" 371 ), 372 icon1 = 373 Icon.Loaded( 374 FakeCustomizationProviderClient.ICON_1, 375 Text.Loaded("Left shortcut"), 376 ), 377 icon2 = 378 Icon.Loaded( 379 FakeCustomizationProviderClient.ICON_3, 380 Text.Loaded("Right shortcut"), 381 ), 382 ) 383 ) 384 } 385 386 @Test 387 fun `summary - affordance selected only on bottom-start`() = 388 testScope.runTest { 389 val slots = collectLastValue(underTest.slots) 390 val quickAffordances = collectLastValue(underTest.quickAffordances) 391 val summary = collectLastValue(underTest.summary) 392 393 // Select "affordance 1" for the first slot. 394 selectAffordance(quickAffordances, 1) 395 396 assertThat(summary()) 397 .isEqualTo( 398 KeyguardQuickAffordanceSummaryViewModel( 399 description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_1), 400 icon1 = 401 Icon.Loaded( 402 FakeCustomizationProviderClient.ICON_1, 403 Text.Loaded("Left shortcut"), 404 ), 405 icon2 = null, 406 ) 407 ) 408 } 409 410 @Test 411 fun `summary - affordance selected only on bottom-end`() = 412 testScope.runTest { 413 val slots = collectLastValue(underTest.slots) 414 val quickAffordances = collectLastValue(underTest.quickAffordances) 415 val summary = collectLastValue(underTest.summary) 416 417 // Select an affordance for the second slot. 418 // First, switch to the second slot: 419 slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke() 420 // Second, select the "affordance 3" affordance: 421 selectAffordance(quickAffordances, 3) 422 423 assertThat(summary()) 424 .isEqualTo( 425 KeyguardQuickAffordanceSummaryViewModel( 426 description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3), 427 icon1 = null, 428 icon2 = 429 Icon.Loaded( 430 FakeCustomizationProviderClient.ICON_3, 431 Text.Loaded("Right shortcut"), 432 ), 433 ) 434 ) 435 } 436 437 @Test 438 fun `summary - no affordances selected`() = 439 testScope.runTest { 440 val summary = collectLastValue(underTest.summary) 441 442 assertThat(summary()?.description) 443 .isEqualTo(Text.Resource(R.string.keyguard_quick_affordance_none_selected)) 444 assertThat(summary()?.icon1).isNotNull() 445 assertThat(summary()?.icon2).isNull() 446 } 447 448 /** Simulates a user selecting the affordance at the given index, if that is clickable. */ 449 private fun TestScope.selectAffordance( 450 affordances: () -> List<OptionItemViewModel<Icon>>?, 451 index: Int, 452 ) { 453 val onClickedFlow = affordances()?.get(index)?.onClicked 454 val onClickedLastValueOrNull: (() -> (() -> Unit)?)? = 455 onClickedFlow?.let { collectLastValue(it) } 456 onClickedLastValueOrNull?.let { onClickedLastValue -> 457 val onClickedOrNull: (() -> Unit)? = onClickedLastValue() 458 onClickedOrNull?.let { onClicked -> onClicked() } 459 } 460 } 461 462 /** 463 * Asserts the entire picker UI state is what is expected. This includes the slot tabs and the 464 * affordance list. 465 * 466 * @param slots The observed slot view-models, keyed by slot ID 467 * @param affordances The observed affordances 468 * @param selectedSlotText The text of the slot that's expected to be selected 469 * @param selectedAffordanceText The text of the affordance that's expected to be selected 470 */ 471 private fun TestScope.assertPickerUiState( 472 slots: Map<String, KeyguardQuickAffordanceSlotViewModel>?, 473 affordances: List<OptionItemViewModel<Icon>>?, 474 selectedSlotText: String, 475 selectedAffordanceText: String, 476 ) { 477 assertSlotTabUiState( 478 slots = slots, 479 slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, 480 isSelected = "Left button" == selectedSlotText, 481 ) 482 assertSlotTabUiState( 483 slots = slots, 484 slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, 485 isSelected = "Right button" == selectedSlotText, 486 ) 487 488 var foundSelectedAffordance = false 489 assertThat(affordances).isNotNull() 490 affordances?.forEach { affordance -> 491 val nameMatchesSelectedName = 492 Text.evaluationEquals(context, affordance.text, Text.Loaded(selectedAffordanceText)) 493 val isSelected: Boolean? = collectLastValue(affordance.isSelected).invoke() 494 assertWithMessage( 495 "Expected affordance with name \"${affordance.text}\" to have" + 496 " isSelected=$nameMatchesSelectedName but it was $isSelected" 497 ) 498 .that(isSelected) 499 .isEqualTo(nameMatchesSelectedName) 500 foundSelectedAffordance = foundSelectedAffordance || nameMatchesSelectedName 501 } 502 assertWithMessage("No affordance is selected!").that(foundSelectedAffordance).isTrue() 503 } 504 505 /** 506 * Asserts that a slot tab has the correct UI state. 507 * 508 * @param slots The observed slot view-models, keyed by slot ID 509 * @param slotId the ID of the slot to assert 510 * @param isSelected Whether that slot should be selected 511 */ 512 private fun assertSlotTabUiState( 513 slots: Map<String, KeyguardQuickAffordanceSlotViewModel>?, 514 slotId: String, 515 isSelected: Boolean, 516 ) { 517 val viewModel = slots?.get(slotId) ?: error("No slot with ID \"$slotId\"!") 518 assertThat(viewModel.isSelected).isEqualTo(isSelected) 519 } 520 521 /** 522 * Asserts the UI state of the preview. 523 * 524 * @param slots The observed slot view-models, keyed by slot ID 525 * @param expectedAffordanceNameBySlotId The expected name of the selected affordance for each 526 * slot ID or `null` if it's expected for there to be no affordance for that slot in the 527 * preview 528 */ 529 private fun assertPreviewUiState( 530 slots: Map<String, KeyguardQuickAffordanceSlotViewModel>?, 531 expectedAffordanceNameBySlotId: Map<String, String?>, 532 ) { 533 assertThat(slots).isNotNull() 534 slots?.forEach { (slotId, slotViewModel) -> 535 val expectedAffordanceName = expectedAffordanceNameBySlotId[slotId] 536 val actualAffordanceName = slotViewModel.selectedQuickAffordances.firstOrNull()?.text 537 assertWithMessage( 538 "At slotId=\"$slotId\", expected affordance=\"$expectedAffordanceName\" but" + 539 " was \"${actualAffordanceName?.asString(context)}\"!" 540 ) 541 .that(actualAffordanceName?.asString(context)) 542 .isEqualTo(expectedAffordanceName) 543 } 544 } 545 } 546