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