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