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