1 /*
<lambda>null2  * Copyright (C) 2023 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 package com.android.wallpaper.picker.preview.ui.viewmodel
17 
18 import android.app.WallpaperColors
19 import android.content.Context
20 import android.graphics.Bitmap
21 import android.graphics.Point
22 import android.graphics.Rect
23 import androidx.annotation.VisibleForTesting
24 import com.android.wallpaper.asset.Asset
25 import com.android.wallpaper.module.WallpaperPreferences
26 import com.android.wallpaper.picker.customization.shared.model.WallpaperColorsModel
27 import com.android.wallpaper.picker.data.WallpaperModel.StaticWallpaperModel
28 import com.android.wallpaper.picker.di.modules.BackgroundDispatcher
29 import com.android.wallpaper.picker.preview.domain.interactor.WallpaperPreviewInteractor
30 import com.android.wallpaper.picker.preview.shared.model.FullPreviewCropModel
31 import com.android.wallpaper.picker.preview.ui.WallpaperPreviewActivity
32 import com.android.wallpaper.util.DisplaysProvider
33 import dagger.hilt.android.qualifiers.ApplicationContext
34 import dagger.hilt.android.scopes.ViewModelScoped
35 import javax.inject.Inject
36 import kotlinx.coroutines.CancellableContinuation
37 import kotlinx.coroutines.CoroutineDispatcher
38 import kotlinx.coroutines.CoroutineScope
39 import kotlinx.coroutines.flow.Flow
40 import kotlinx.coroutines.flow.MutableStateFlow
41 import kotlinx.coroutines.flow.SharingStarted
42 import kotlinx.coroutines.flow.combine
43 import kotlinx.coroutines.flow.distinctUntilChanged
44 import kotlinx.coroutines.flow.filter
45 import kotlinx.coroutines.flow.filterNotNull
46 import kotlinx.coroutines.flow.flowOn
47 import kotlinx.coroutines.flow.map
48 import kotlinx.coroutines.flow.shareIn
49 import kotlinx.coroutines.suspendCancellableCoroutine
50 
51 /** View model for static wallpaper preview used in [WallpaperPreviewActivity] and its fragments */
52 @ViewModelScoped
53 class StaticWallpaperPreviewViewModel
54 @Inject
55 constructor(
56     interactor: WallpaperPreviewInteractor,
57     @ApplicationContext private val context: Context,
58     private val wallpaperPreferences: WallpaperPreferences,
59     @BackgroundDispatcher private val bgDispatcher: CoroutineDispatcher,
60     viewModelScope: CoroutineScope,
61     displaysProvider: DisplaysProvider,
62 ) {
63     /**
64      * The state of static wallpaper crop in full preview, before user confirmation.
65      *
66      * The initial value should be the default crop on small preview, which could be the cropHints
67      * for current wallpaper or default crop area for a new wallpaper.
68      */
69     val fullPreviewCropModels: MutableMap<Point, FullPreviewCropModel> = mutableMapOf()
70 
71     /**
72      * The default crops for the current wallpaper, which is center aligned on the preview.
73      *
74      * Always update default through [updateDefaultPreviewCropModel] to make sure multiple updates
75      * of the same preview only counts the first time it appears.
76      */
77     private val defaultPreviewCropModels: MutableMap<Point, FullPreviewCropModel> = mutableMapOf()
78 
79     /**
80      * The info picker needs to post process crops for setting static wallpaper.
81      *
82      * It will be filled with current cropHints when previewing current wallpaper, and null when
83      * previewing a new wallpaper, and gets updated through [updateCropHintsInfo] when user picks a
84      * new crop.
85      */
86     @get:VisibleForTesting
87     val cropHintsInfo: MutableStateFlow<Map<Point, FullPreviewCropModel>?> = MutableStateFlow(null)
88 
89     private val cropHints: Flow<Map<Point, Rect>?> =
90         cropHintsInfo.map { cropHintsInfoMap ->
91             cropHintsInfoMap?.map { entry -> entry.key to entry.value.cropHint }?.toMap()
92         }
93 
94     val staticWallpaperModel: Flow<StaticWallpaperModel> =
95         interactor.wallpaperModel.map { it as? StaticWallpaperModel }.filterNotNull()
96 
97     /** Null indicates the wallpaper has no low res image. */
98     val lowResBitmap: Flow<Bitmap?> =
99         staticWallpaperModel
100             .map { it.staticWallpaperData.asset.getLowResBitmap(context) }
101             .flowOn(bgDispatcher)
102     // Asset detail includes the dimensions, bitmap and the asset.
103     private val assetDetail: Flow<Triple<Point, Bitmap?, Asset>?> =
104         interactor.wallpaperModel
105             .map { (it as? StaticWallpaperModel)?.staticWallpaperData?.asset }
106             .map { asset ->
107                 asset?.decodeRawDimensions()?.let { Triple(it, asset.decodeBitmap(it), asset) }
108             }
109             .flowOn(bgDispatcher)
110             // We only want to decode bitmap every time when wallpaper model is updated, instead of
111             // a new subscriber listens to this flow. So we need to use shareIn.
112             .shareIn(viewModelScope, SharingStarted.Lazily, 1)
113 
114     val fullResWallpaperViewModel: Flow<FullResWallpaperViewModel?> =
115         combine(assetDetail, cropHintsInfo) { assetDetail, cropHintsInfo ->
116                 if (assetDetail == null) {
117                     null
118                 } else {
119                     val (dimensions, bitmap, asset) = assetDetail
120                     bitmap?.let {
121                         FullResWallpaperViewModel(bitmap, dimensions, asset, cropHintsInfo)
122                     }
123                 }
124             }
125             .flowOn(bgDispatcher)
126     val subsamplingScaleImageViewModel: Flow<FullResWallpaperViewModel> =
127         fullResWallpaperViewModel.filterNotNull()
128 
129     // At least as many crops as how many displays, it could be more due to the orientation. Or when
130     // no crops ever set, unblocks down stream for default behavior.
131     private val hasAllDisplayCrops: Flow<Boolean> =
132         cropHintsInfo.map { it == null || it.size >= displaysProvider.getInternalDisplays().size }
133 
134     // TODO (b/315856338): cache wallpaper colors in preferences
135     private val storedWallpaperColors: Flow<WallpaperColors?> =
136         staticWallpaperModel
137             .map { wallpaperPreferences.getWallpaperColors(it.commonWallpaperData.id.uniqueId) }
138             .distinctUntilChanged()
139     val wallpaperColors: Flow<WallpaperColorsModel> =
140         combine(
141                 storedWallpaperColors,
142                 subsamplingScaleImageViewModel,
143                 cropHints,
144                 hasAllDisplayCrops.filter { it },
145             ) { storedColors, wallpaperViewModel, cropHints, _ ->
146                 WallpaperColorsModel.Loaded(
147                     if (cropHints == null) {
148                         storedColors
149                             ?: interactor.getWallpaperColors(
150                                 wallpaperViewModel.rawWallpaperBitmap,
151                                 null,
152                             )
153                     } else {
154                         interactor.getWallpaperColors(
155                             wallpaperViewModel.rawWallpaperBitmap,
156                             cropHints,
157                         )
158                     }
159                 )
160             }
161             .distinctUntilChanged()
162 
163     /**
164      * Updates new cropHints per displaySize that's been confirmed by the user or from a new default
165      * crop.
166      *
167      * That's when picker gets current cropHints from [WallpaperManager] or when user crops and
168      * confirms a crop, or when a small preview for a new display size has been discovered the first
169      * time.
170      */
171     fun updateCropHintsInfo(
172         cropHintsInfo: Map<Point, FullPreviewCropModel>,
173         updateDefaultCrop: Boolean = false,
174     ) {
175         val newInfo =
176             this.cropHintsInfo.value?.let { currentCropHintsInfo ->
177                 currentCropHintsInfo.plus(
178                     if (updateDefaultCrop)
179                         cropHintsInfo.filterKeys { !currentCropHintsInfo.keys.contains(it) }
180                     else cropHintsInfo
181                 )
182             } ?: cropHintsInfo
183         this.cropHintsInfo.value = newInfo
184         fullPreviewCropModels.putAll(newInfo)
185     }
186 
187     /** Updates default cropHint for [displaySize] if it's not already exist. */
188     fun updateDefaultPreviewCropModel(displaySize: Point, cropModel: FullPreviewCropModel) {
189         defaultPreviewCropModels.let { cropModels ->
190             if (!cropModels.contains(displaySize)) {
191                 cropModels[displaySize] = cropModel
192                 updateCropHintsInfo(
193                     cropModels.filterKeys { it == displaySize },
194                     updateDefaultCrop = true,
195                 )
196             }
197         }
198     }
199 
200     // TODO b/296288298 Create a util class for Bitmap and Asset
201     private suspend fun Asset.decodeRawDimensions(): Point? =
202         suspendCancellableCoroutine { k: CancellableContinuation<Point?> ->
203             val callback = Asset.DimensionsReceiver { k.resumeWith(Result.success(it)) }
204             decodeRawDimensions(null, callback)
205         }
206 
207     // TODO b/296288298 Create a util class functions for Bitmap and Asset
208     private suspend fun Asset.decodeBitmap(dimensions: Point): Bitmap? =
209         suspendCancellableCoroutine { k: CancellableContinuation<Bitmap?> ->
210             val callback = Asset.BitmapReceiver { k.resumeWith(Result.success(it)) }
211             decodeBitmap(dimensions.x, dimensions.y, /* hardwareBitmapAllowed= */ false, callback)
212         }
213 
214     class Factory
215     @Inject
216     constructor(
217         private val interactor: WallpaperPreviewInteractor,
218         @ApplicationContext private val context: Context,
219         private val wallpaperPreferences: WallpaperPreferences,
220         @BackgroundDispatcher private val bgDispatcher: CoroutineDispatcher,
221         private val displaysProvider: DisplaysProvider,
222     ) {
223         fun create(viewModelScope: CoroutineScope): StaticWallpaperPreviewViewModel {
224             return StaticWallpaperPreviewViewModel(
225                 interactor = interactor,
226                 context = context,
227                 wallpaperPreferences = wallpaperPreferences,
228                 bgDispatcher = bgDispatcher,
229                 viewModelScope = viewModelScope,
230                 displaysProvider = displaysProvider,
231             )
232         }
233     }
234 }
235