| /* |
| * Copyright (C) 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| * |
| */ |
| |
| package com.android.customization.picker.quickaffordance.ui.viewmodel |
| |
| import android.annotation.SuppressLint |
| import android.content.Context |
| import android.content.Intent |
| import android.graphics.drawable.Drawable |
| import android.os.Bundle |
| import androidx.annotation.DrawableRes |
| import androidx.lifecycle.ViewModel |
| import androidx.lifecycle.ViewModelProvider |
| import androidx.lifecycle.viewModelScope |
| import com.android.customization.picker.quickaffordance.domain.interactor.KeyguardQuickAffordancePickerInteractor |
| import com.android.systemui.shared.customization.data.content.CustomizationProviderContract |
| import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots |
| import com.android.systemui.shared.quickaffordance.shared.model.KeyguardQuickAffordancePreviewConstants |
| import com.android.wallpaper.R |
| import com.android.wallpaper.module.CurrentWallpaperInfoFactory |
| import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle |
| import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel |
| import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel |
| import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon |
| import com.android.wallpaper.picker.common.text.ui.viewmodel.Text |
| import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel |
| import com.android.wallpaper.picker.undo.domain.interactor.UndoInteractor |
| import com.android.wallpaper.picker.undo.ui.viewmodel.UndoViewModel |
| import com.android.wallpaper.util.PreviewUtils |
| import kotlinx.coroutines.ExperimentalCoroutinesApi |
| import kotlinx.coroutines.flow.Flow |
| import kotlinx.coroutines.flow.MutableStateFlow |
| import kotlinx.coroutines.flow.StateFlow |
| import kotlinx.coroutines.flow.asStateFlow |
| import kotlinx.coroutines.flow.combine |
| import kotlinx.coroutines.flow.map |
| import kotlinx.coroutines.launch |
| import kotlinx.coroutines.suspendCancellableCoroutine |
| |
| /** Models UI state for a lock screen quick affordance picker experience. */ |
| @OptIn(ExperimentalCoroutinesApi::class) |
| class KeyguardQuickAffordancePickerViewModel |
| private constructor( |
| context: Context, |
| private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor, |
| undoInteractor: UndoInteractor, |
| private val wallpaperInfoFactory: CurrentWallpaperInfoFactory, |
| activityStarter: (Intent) -> Unit, |
| ) : ViewModel() { |
| |
| @SuppressLint("StaticFieldLeak") private val applicationContext = context.applicationContext |
| |
| val preview = |
| ScreenPreviewViewModel( |
| previewUtils = |
| PreviewUtils( |
| context = applicationContext, |
| authority = |
| applicationContext.getString( |
| R.string.lock_screen_preview_provider_authority, |
| ), |
| ), |
| initialExtrasProvider = { |
| Bundle().apply { |
| putString( |
| KeyguardQuickAffordancePreviewConstants.KEY_INITIALLY_SELECTED_SLOT_ID, |
| selectedSlotId.value, |
| ) |
| } |
| }, |
| wallpaperInfoProvider = { |
| suspendCancellableCoroutine { continuation -> |
| wallpaperInfoFactory.createCurrentWallpaperInfos( |
| { homeWallpaper, lockWallpaper, _ -> |
| continuation.resume(lockWallpaper ?: homeWallpaper, null) |
| }, |
| /* forceRefresh= */ true, |
| ) |
| } |
| }, |
| ) |
| |
| val undo: UndoViewModel = |
| UndoViewModel( |
| interactor = undoInteractor, |
| ) |
| |
| private val _selectedSlotId = MutableStateFlow<String?>(null) |
| val selectedSlotId: StateFlow<String?> = _selectedSlotId.asStateFlow() |
| |
| /** View-models for each slot, keyed by slot ID. */ |
| val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> = |
| combine( |
| quickAffordanceInteractor.slots, |
| quickAffordanceInteractor.affordances, |
| quickAffordanceInteractor.selections, |
| selectedSlotId, |
| ) { slots, affordances, selections, selectedSlotIdOrNull -> |
| slots |
| .mapIndexed { index, slot -> |
| val selectedAffordanceIds = |
| selections |
| .filter { selection -> selection.slotId == slot.id } |
| .map { selection -> selection.affordanceId } |
| .toSet() |
| val selectedAffordances = |
| affordances.filter { affordance -> |
| selectedAffordanceIds.contains(affordance.id) |
| } |
| val isSelected = |
| (selectedSlotIdOrNull == null && index == 0) || |
| selectedSlotIdOrNull == slot.id |
| slot.id to |
| KeyguardQuickAffordanceSlotViewModel( |
| name = getSlotName(slot.id), |
| isSelected = isSelected, |
| selectedQuickAffordances = |
| selectedAffordances.map { affordanceModel -> |
| KeyguardQuickAffordanceViewModel( |
| icon = getAffordanceIcon(affordanceModel.iconResourceId), |
| contentDescription = affordanceModel.name, |
| isSelected = true, |
| onClicked = null, |
| onLongClicked = null, |
| isEnabled = affordanceModel.isEnabled, |
| ) |
| }, |
| maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances, |
| onClicked = |
| if (isSelected) { |
| null |
| } else { |
| { _selectedSlotId.tryEmit(slot.id) } |
| }, |
| ) |
| } |
| .toMap() |
| } |
| |
| /** The list of all available quick affordances for the selected slot. */ |
| val quickAffordances: Flow<List<KeyguardQuickAffordanceViewModel>> = |
| combine( |
| quickAffordanceInteractor.slots, |
| quickAffordanceInteractor.affordances, |
| quickAffordanceInteractor.selections, |
| selectedSlotId, |
| ) { slots, affordances, selections, selectedSlotIdOrNull -> |
| val selectedSlot = |
| selectedSlotIdOrNull?.let { slots.find { slot -> slot.id == it } } ?: slots.first() |
| val selectedAffordanceIds = |
| selections |
| .filter { selection -> selection.slotId == selectedSlot.id } |
| .map { selection -> selection.affordanceId } |
| .toSet() |
| listOf( |
| none( |
| slotId = selectedSlot.id, |
| isSelected = selectedAffordanceIds.isEmpty(), |
| ) |
| ) + |
| affordances.map { affordance -> |
| val isSelected = selectedAffordanceIds.contains(affordance.id) |
| val affordanceIcon = getAffordanceIcon(affordance.iconResourceId) |
| KeyguardQuickAffordanceViewModel( |
| icon = affordanceIcon, |
| contentDescription = affordance.name, |
| isSelected = isSelected, |
| onClicked = |
| if (affordance.isEnabled) { |
| { |
| viewModelScope.launch { |
| if (isSelected) { |
| quickAffordanceInteractor.unselect( |
| slotId = selectedSlot.id, |
| affordanceId = affordance.id, |
| ) |
| } else { |
| quickAffordanceInteractor.select( |
| slotId = selectedSlot.id, |
| affordanceId = affordance.id, |
| ) |
| } |
| } |
| } |
| } else { |
| { |
| showEnablementDialog( |
| icon = affordanceIcon, |
| name = affordance.name, |
| instructions = affordance.enablementInstructions, |
| actionText = affordance.enablementActionText, |
| actionComponentName = |
| affordance.enablementActionComponentName, |
| ) |
| } |
| }, |
| onLongClicked = |
| if (affordance.configureIntent != null) { |
| { activityStarter(affordance.configureIntent) } |
| } else { |
| null |
| }, |
| isEnabled = affordance.isEnabled, |
| ) |
| } |
| } |
| |
| @SuppressLint("UseCompatLoadingForDrawables") |
| val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> = |
| slots.map { slots -> |
| val icon2 = |
| slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END] |
| ?.selectedQuickAffordances |
| ?.firstOrNull() |
| ?.icon |
| val icon1 = |
| slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START] |
| ?.selectedQuickAffordances |
| ?.firstOrNull() |
| ?.icon |
| |
| KeyguardQuickAffordanceSummaryViewModel( |
| description = toDescriptionText(context, slots), |
| icon1 = icon1 |
| ?: if (icon2 == null) { |
| context.getDrawable(R.drawable.link_off) |
| } else { |
| null |
| }, |
| icon2 = icon2, |
| ) |
| } |
| |
| private val _dialog = MutableStateFlow<DialogViewModel?>(null) |
| /** |
| * The current dialog to show. If `null`, no dialog should be shown. |
| * |
| * When the dialog is dismissed, [onDialogDismissed] must be called. |
| */ |
| val dialog: Flow<DialogViewModel?> = _dialog.asStateFlow() |
| |
| /** Notifies that the dialog has been dismissed in the UI. */ |
| fun onDialogDismissed() { |
| _dialog.value = null |
| } |
| |
| private fun showEnablementDialog( |
| icon: Drawable, |
| name: String, |
| instructions: List<String>, |
| actionText: String?, |
| actionComponentName: String?, |
| ) { |
| _dialog.value = |
| DialogViewModel( |
| icon = |
| Icon.Loaded( |
| drawable = icon, |
| contentDescription = null, |
| ), |
| title = Text.Loaded(name), |
| message = |
| Text.Loaded( |
| buildString { |
| instructions.forEachIndexed { index, instruction -> |
| if (index > 0) { |
| append('\n') |
| } |
| |
| append(instruction) |
| } |
| } |
| ), |
| buttons = |
| listOf( |
| ButtonViewModel( |
| text = actionText?.let { Text.Loaded(actionText) } |
| ?: Text.Resource( |
| R.string |
| .keyguard_affordance_enablement_dialog_dismiss_button, |
| ), |
| style = ButtonStyle.Primary, |
| onClicked = { |
| actionComponentName.toIntent()?.let { intent -> |
| applicationContext.startActivity(intent) |
| } |
| } |
| ), |
| ), |
| ) |
| } |
| |
| @SuppressLint("UseCompatLoadingForDrawables") |
| private fun none( |
| slotId: String, |
| isSelected: Boolean, |
| ): KeyguardQuickAffordanceViewModel { |
| return KeyguardQuickAffordanceViewModel.none( |
| context = applicationContext, |
| isSelected = isSelected, |
| onSelected = { |
| viewModelScope.launch { quickAffordanceInteractor.unselectAll(slotId) } |
| }, |
| ) |
| } |
| |
| private fun getSlotName(slotId: String): String { |
| return applicationContext.getString( |
| when (slotId) { |
| KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START -> |
| R.string.keyguard_slot_name_bottom_start |
| KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END -> |
| R.string.keyguard_slot_name_bottom_end |
| else -> error("No name for slot with ID of \"$slotId\"!") |
| } |
| ) |
| } |
| |
| private suspend fun getAffordanceIcon(@DrawableRes iconResourceId: Int): Drawable { |
| return quickAffordanceInteractor.getAffordanceIcon(iconResourceId) |
| } |
| |
| private fun String?.toIntent(): Intent? { |
| if (isNullOrEmpty()) { |
| return null |
| } |
| |
| val splitUp = |
| split( |
| CustomizationProviderContract.LockScreenQuickAffordances.AffordanceTable |
| .COMPONENT_NAME_SEPARATOR |
| ) |
| check(splitUp.size == 1 || splitUp.size == 2) { |
| "Illegal component name \"$this\". Must be either just an action or a package and an" + |
| " action separated by a" + |
| " \"${CustomizationProviderContract.LockScreenQuickAffordances.AffordanceTable.COMPONENT_NAME_SEPARATOR}\"!" |
| } |
| |
| return Intent(splitUp.last()).apply { |
| if (splitUp.size > 1) { |
| setPackage(splitUp[0]) |
| } |
| } |
| } |
| |
| private fun toDescriptionText( |
| context: Context, |
| slots: Map<String, KeyguardQuickAffordanceSlotViewModel>, |
| ): String { |
| val bottomStartAffordanceName = |
| slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START] |
| ?.selectedQuickAffordances |
| ?.firstOrNull() |
| ?.contentDescription |
| val bottomEndAffordanceName = |
| slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END] |
| ?.selectedQuickAffordances |
| ?.firstOrNull() |
| ?.contentDescription |
| |
| return when { |
| !bottomStartAffordanceName.isNullOrEmpty() && |
| !bottomEndAffordanceName.isNullOrEmpty() -> { |
| context.getString( |
| R.string.keyguard_quick_affordance_two_selected_template, |
| bottomStartAffordanceName, |
| bottomEndAffordanceName, |
| ) |
| } |
| !bottomStartAffordanceName.isNullOrEmpty() -> bottomStartAffordanceName |
| !bottomEndAffordanceName.isNullOrEmpty() -> bottomEndAffordanceName |
| else -> context.getString(R.string.keyguard_quick_affordance_none_selected) |
| } |
| } |
| |
| class Factory( |
| private val context: Context, |
| private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor, |
| private val undoInteractor: UndoInteractor, |
| private val wallpaperInfoFactory: CurrentWallpaperInfoFactory, |
| private val activityStarter: (Intent) -> Unit, |
| ) : ViewModelProvider.Factory { |
| override fun <T : ViewModel> create(modelClass: Class<T>): T { |
| @Suppress("UNCHECKED_CAST") |
| return KeyguardQuickAffordancePickerViewModel( |
| context = context, |
| quickAffordanceInteractor = quickAffordanceInteractor, |
| undoInteractor = undoInteractor, |
| wallpaperInfoFactory = wallpaperInfoFactory, |
| activityStarter = activityStarter, |
| ) |
| as T |
| } |
| } |
| } |