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