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.picker.quickaffordance.ui.viewmodel
19 
20 import android.annotation.SuppressLint
21 import android.content.Context
22 import android.content.Intent
23 import android.graphics.drawable.Drawable
24 import android.os.Bundle
25 import androidx.annotation.DrawableRes
26 import androidx.lifecycle.ViewModel
27 import androidx.lifecycle.ViewModelProvider
28 import androidx.lifecycle.viewModelScope
29 import com.android.customization.module.logging.ThemesUserEventLogger
30 import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor
31 import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
32 import com.android.systemui.shared.quickaffordance.shared.model.KeyguardPreviewConstants
33 import com.android.themepicker.R
34 import com.android.wallpaper.model.Screen
35 import com.android.wallpaper.module.CurrentWallpaperInfoFactory
36 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle
37 import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel
38 import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
39 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
40 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
41 import com.android.wallpaper.picker.customization.domain.interactor.WallpaperInteractor
42 import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel
43 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
44 import com.android.wallpaper.util.PreviewUtils
45 import kotlinx.coroutines.ExperimentalCoroutinesApi
46 import kotlinx.coroutines.flow.Flow
47 import kotlinx.coroutines.flow.MutableStateFlow
48 import kotlinx.coroutines.flow.SharingStarted
49 import kotlinx.coroutines.flow.StateFlow
50 import kotlinx.coroutines.flow.asStateFlow
51 import kotlinx.coroutines.flow.combine
52 import kotlinx.coroutines.flow.flowOf
53 import kotlinx.coroutines.flow.map
54 import kotlinx.coroutines.flow.shareIn
55 import kotlinx.coroutines.flow.stateIn
56 import kotlinx.coroutines.launch
57 import kotlinx.coroutines.suspendCancellableCoroutine
58 
59 /** Models UI state for a lock screen quick affordance picker experience. */
60 @OptIn(ExperimentalCoroutinesApi::class)
61 class KeyguardQuickAffordancePickerViewModel
62 private constructor(
63     context: Context,
64     private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor,
65     private val wallpaperInteractor: WallpaperInteractor,
66     private val wallpaperInfoFactory: CurrentWallpaperInfoFactory,
67     private val logger: ThemesUserEventLogger,
68 ) : ViewModel() {
69 
70     @SuppressLint("StaticFieldLeak") private val applicationContext = context.applicationContext
71 
72     val preview =
73         ScreenPreviewViewModel(
74             previewUtils =
75                 PreviewUtils(
76                     context = applicationContext,
77                     authority =
78                         applicationContext.getString(
79                             com.android.wallpaper.R.string.lock_screen_preview_provider_authority,
80                         ),
81                 ),
82             initialExtrasProvider = {
83                 Bundle().apply {
84                     putString(
85                         KeyguardPreviewConstants.KEY_INITIALLY_SELECTED_SLOT_ID,
86                         selectedSlotId.value,
87                     )
88                     putBoolean(
89                         KeyguardPreviewConstants.KEY_HIGHLIGHT_QUICK_AFFORDANCES,
90                         true,
91                     )
92                 }
93             },
94             wallpaperInfoProvider = { forceReload ->
95                 suspendCancellableCoroutine { continuation ->
96                     wallpaperInfoFactory.createCurrentWallpaperInfos(
97                         context,
98                         forceReload,
99                     ) { homeWallpaper, lockWallpaper, _ ->
100                         continuation.resume(lockWallpaper ?: homeWallpaper, null)
101                     }
102                 }
103             },
104             wallpaperInteractor = wallpaperInteractor,
105             screen = Screen.LOCK_SCREEN,
106         )
107 
108     /** A locally-selected slot, if the user ever switched from the original one. */
109     private val _selectedSlotId = MutableStateFlow<String?>(null)
110     /** The ID of the selected slot. */
111     val selectedSlotId: StateFlow<String> =
112         combine(
113                 quickAffordanceInteractor.slots,
114                 _selectedSlotId,
115             ) { slots, selectedSlotIdOrNull ->
116                 if (selectedSlotIdOrNull != null) {
117                     slots.first { slot -> slot.id == selectedSlotIdOrNull }
118                 } else {
119                     // If we haven't yet selected a new slot locally, default to the first slot.
120                     slots[0]
121                 }
122             }
123             .map { selectedSlot -> selectedSlot.id }
124             .stateIn(
125                 scope = viewModelScope,
126                 started = SharingStarted.WhileSubscribed(),
127                 initialValue = "",
128             )
129 
130     /** View-models for each slot, keyed by slot ID. */
131     val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
132         combine(
133             quickAffordanceInteractor.slots,
134             quickAffordanceInteractor.affordances,
135             quickAffordanceInteractor.selections,
136             selectedSlotId,
137         ) { slots, affordances, selections, selectedSlotId ->
138             slots.associate { slot ->
139                 val selectedAffordanceIds =
140                     selections
141                         .filter { selection -> selection.slotId == slot.id }
142                         .map { selection -> selection.affordanceId }
143                         .toSet()
144                 val selectedAffordances =
145                     affordances.filter { affordance ->
146                         selectedAffordanceIds.contains(affordance.id)
147                     }
148                 val isSelected = selectedSlotId == slot.id
149                 slot.id to
150                     KeyguardQuickAffordanceSlotViewModel(
151                         name = getSlotName(slot.id),
152                         isSelected = isSelected,
153                         selectedQuickAffordances =
154                             selectedAffordances.map { affordanceModel ->
155                                 OptionItemViewModel<Icon>(
156                                     key =
157                                         MutableStateFlow("${slot.id}::${affordanceModel.id}")
158                                             as StateFlow<String>,
159                                     payload =
160                                         Icon.Loaded(
161                                             drawable =
162                                                 getAffordanceIcon(affordanceModel.iconResourceId),
163                                             contentDescription =
164                                                 Text.Loaded(getSlotContentDescription(slot.id)),
165                                         ),
166                                     text = Text.Loaded(affordanceModel.name),
167                                     isSelected = MutableStateFlow(true) as StateFlow<Boolean>,
168                                     onClicked = flowOf(null),
169                                     onLongClicked = null,
170                                     isEnabled = true,
171                                 )
172                             },
173                         maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
174                         onClicked =
175                             if (isSelected) {
176                                 null
177                             } else {
178                                 { _selectedSlotId.tryEmit(slot.id) }
179                             },
180                     )
181             }
182         }
183 
184     /**
185      * The set of IDs of the currently-selected affordances. These change with user selection of new
186      * or different affordances in the currently-selected slot or when slot selection changes.
187      */
188     private val selectedAffordanceIds: Flow<Set<String>> =
189         combine(
190                 quickAffordanceInteractor.selections,
191                 selectedSlotId,
192             ) { selections, selectedSlotId ->
193                 selections
194                     .filter { selection -> selection.slotId == selectedSlotId }
195                     .map { selection -> selection.affordanceId }
196                     .toSet()
197             }
198             .shareIn(
199                 scope = viewModelScope,
200                 started = SharingStarted.WhileSubscribed(),
201                 replay = 1,
202             )
203 
204     /** The list of all available quick affordances for the selected slot. */
205     val quickAffordances: Flow<List<OptionItemViewModel<Icon>>> =
206         quickAffordanceInteractor.affordances.map { affordances ->
207             val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }.stateIn(viewModelScope)
208             listOf(
209                 none(
210                     slotId = selectedSlotId,
211                     isSelected = isNoneSelected,
212                     onSelected =
213                         combine(
214                             isNoneSelected,
215                             selectedSlotId,
216                         ) { isSelected, selectedSlotId ->
217                             if (!isSelected) {
218                                 {
219                                     viewModelScope.launch {
220                                         quickAffordanceInteractor.unselectAllFromSlot(
221                                             selectedSlotId
222                                         )
223                                         logger.logShortcutApplied(
224                                             shortcut = "none",
225                                             shortcutSlotId = selectedSlotId,
226                                         )
227                                     }
228                                 }
229                             } else {
230                                 null
231                             }
232                         }
233                 )
234             ) +
235                 affordances.map { affordance ->
236                     val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
237                     val isSelectedFlow: StateFlow<Boolean> =
238                         selectedAffordanceIds
239                             .map { it.contains(affordance.id) }
240                             .stateIn(viewModelScope)
241                     OptionItemViewModel<Icon>(
242                         key =
243                             selectedSlotId
244                                 .map { slotId -> "$slotId::${affordance.id}" }
245                                 .stateIn(viewModelScope),
246                         payload = Icon.Loaded(drawable = affordanceIcon, contentDescription = null),
247                         text = Text.Loaded(affordance.name),
248                         isSelected = isSelectedFlow,
249                         onClicked =
250                             if (affordance.isEnabled) {
251                                 combine(
252                                     isSelectedFlow,
253                                     selectedSlotId,
254                                 ) { isSelected, selectedSlotId ->
255                                     if (!isSelected) {
256                                         {
257                                             viewModelScope.launch {
258                                                 quickAffordanceInteractor.select(
259                                                     slotId = selectedSlotId,
260                                                     affordanceId = affordance.id,
261                                                 )
262                                                 logger.logShortcutApplied(
263                                                     shortcut = affordance.id,
264                                                     shortcutSlotId = selectedSlotId,
265                                                 )
266                                             }
267                                         }
268                                     } else {
269                                         null
270                                     }
271                                 }
272                             } else {
273                                 flowOf {
274                                     showEnablementDialog(
275                                         icon = affordanceIcon,
276                                         name = affordance.name,
277                                         explanation = affordance.enablementExplanation,
278                                         actionText = affordance.enablementActionText,
279                                         actionIntent = affordance.enablementActionIntent,
280                                     )
281                                 }
282                             },
283                         onLongClicked =
284                             if (affordance.configureIntent != null) {
285                                 { requestActivityStart(affordance.configureIntent) }
286                             } else {
287                                 null
288                             },
289                         isEnabled = affordance.isEnabled,
290                     )
291                 }
292         }
293 
294     @SuppressLint("UseCompatLoadingForDrawables")
295     val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> =
296         slots.map { slots ->
297             val icon2 =
298                 (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
299                         ?.selectedQuickAffordances
300                         ?.firstOrNull())
301                     ?.payload
302             val icon1 =
303                 (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
304                         ?.selectedQuickAffordances
305                         ?.firstOrNull())
306                     ?.payload
307 
308             KeyguardQuickAffordanceSummaryViewModel(
309                 description = toDescriptionText(context, slots),
310                 icon1 =
311                     icon1
312                         ?: if (icon2 == null) {
313                             Icon.Resource(
314                                 res = R.drawable.link_off,
315                                 contentDescription = null,
316                             )
317                         } else {
318                             null
319                         },
320                 icon2 = icon2,
321             )
322         }
323 
324     private val _dialog = MutableStateFlow<DialogViewModel?>(null)
325     /**
326      * The current dialog to show. If `null`, no dialog should be shown.
327      *
328      * When the dialog is dismissed, [onDialogDismissed] must be called.
329      */
330     val dialog: Flow<DialogViewModel?> = _dialog.asStateFlow()
331 
332     private val _activityStartRequests = MutableStateFlow<Intent?>(null)
333     /**
334      * Requests to start an activity with the given [Intent].
335      *
336      * Important: once the activity is started, the [Intent] should be consumed by calling
337      * [onActivityStarted].
338      */
339     val activityStartRequests: StateFlow<Intent?> = _activityStartRequests.asStateFlow()
340 
341     /** Notifies that the dialog has been dismissed in the UI. */
342     fun onDialogDismissed() {
343         _dialog.value = null
344     }
345 
346     /**
347      * Notifies that an activity request from [activityStartRequests] has been fulfilled (e.g. the
348      * activity was started and the view-model can forget needing to start this activity).
349      */
350     fun onActivityStarted() {
351         _activityStartRequests.value = null
352     }
353 
354     private fun requestActivityStart(
355         intent: Intent,
356     ) {
357         _activityStartRequests.value = intent
358     }
359 
360     private fun showEnablementDialog(
361         icon: Drawable,
362         name: String,
363         explanation: String,
364         actionText: String?,
365         actionIntent: Intent?,
366     ) {
367         _dialog.value =
368             DialogViewModel(
369                 icon =
370                     Icon.Loaded(
371                         drawable = icon,
372                         contentDescription = null,
373                     ),
374                 headline = Text.Resource(R.string.keyguard_affordance_enablement_dialog_headline),
375                 message = Text.Loaded(explanation),
376                 buttons =
377                     buildList {
378                         add(
379                             ButtonViewModel(
380                                 text =
381                                     Text.Resource(
382                                         if (actionText != null) {
383                                             // This is not the only button on the dialog.
384                                             R.string.cancel
385                                         } else {
386                                             // This is the only button on the dialog.
387                                             R.string
388                                                 .keyguard_affordance_enablement_dialog_dismiss_button
389                                         }
390                                     ),
391                                 style = ButtonStyle.Secondary,
392                             ),
393                         )
394 
395                         if (actionText != null) {
396                             add(
397                                 ButtonViewModel(
398                                     text = Text.Loaded(actionText),
399                                     style = ButtonStyle.Primary,
400                                     onClicked = {
401                                         actionIntent?.let { intent -> requestActivityStart(intent) }
402                                     }
403                                 ),
404                             )
405                         }
406                     },
407             )
408     }
409 
410     /** Returns a view-model for the special "None" option. */
411     @SuppressLint("UseCompatLoadingForDrawables")
412     private suspend fun none(
413         slotId: StateFlow<String>,
414         isSelected: StateFlow<Boolean>,
415         onSelected: Flow<(() -> Unit)?>,
416     ): OptionItemViewModel<Icon> {
417         return OptionItemViewModel<Icon>(
418             key = slotId.map { "$it::none" }.stateIn(viewModelScope),
419             payload = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
420             text = Text.Resource(res = R.string.keyguard_affordance_none),
421             isSelected = isSelected,
422             onClicked = onSelected,
423             onLongClicked = null,
424             isEnabled = true,
425         )
426     }
427 
428     private fun getSlotName(slotId: String): String {
429         return applicationContext.getString(
430             when (slotId) {
431                 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
432                     R.string.keyguard_slot_name_bottom_start
433                 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
434                     R.string.keyguard_slot_name_bottom_end
435                 else -> error("No name for slot with ID of \"$slotId\"!")
436             }
437         )
438     }
439 
440     private fun getSlotContentDescription(slotId: String): String {
441         return applicationContext.getString(
442             when (slotId) {
443                 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START ->
444                     R.string.keyguard_slot_name_bottom_start
445                 KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END ->
446                     R.string.keyguard_slot_name_bottom_end
447                 else -> error("No accessibility label for slot with ID \"$slotId\"!")
448             }
449         )
450     }
451 
452     private suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable {
453         return quickAffordanceInteractor.getAffordanceIcon(iconResourceId)
454     }
455 
456     private fun toDescriptionText(
457         context: Context,
458         slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
459     ): Text {
460         val bottomStartAffordanceName =
461             slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
462                 ?.selectedQuickAffordances
463                 ?.firstOrNull()
464                 ?.text
465         val bottomEndAffordanceName =
466             slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
467                 ?.selectedQuickAffordances
468                 ?.firstOrNull()
469                 ?.text
470 
471         return when {
472             bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
473                 Text.Loaded(
474                     context.getString(
475                         R.string.keyguard_quick_affordance_two_selected_template,
476                         bottomStartAffordanceName.asString(context),
477                         bottomEndAffordanceName.asString(context),
478                     )
479                 )
480             }
481             bottomStartAffordanceName != null -> bottomStartAffordanceName
482             bottomEndAffordanceName != null -> bottomEndAffordanceName
483             else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
484         }
485     }
486 
487     class Factory(
488         private val context: Context,
489         private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor,
490         private val wallpaperInteractor: WallpaperInteractor,
491         private val wallpaperInfoFactory: CurrentWallpaperInfoFactory,
492         private val logger: ThemesUserEventLogger,
493     ) : ViewModelProvider.Factory {
494         override fun <T : ViewModel> create(modelClass: Class<T>): T {
495             @Suppress("UNCHECKED_CAST")
496             return KeyguardQuickAffordancePickerViewModel(
497                 context = context,
498                 quickAffordanceInteractor = quickAffordanceInteractor,
499                 wallpaperInteractor = wallpaperInteractor,
500                 wallpaperInfoFactory = wallpaperInfoFactory,
501                 logger = logger,
502             )
503                 as T
504         }
505     }
506 }
507