• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2024 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 package com.android.photopicker.features.photogrid
18 
19 import android.util.Log
20 import androidx.lifecycle.ViewModel
21 import androidx.lifecycle.viewModelScope
22 import androidx.paging.Pager
23 import androidx.paging.PagingConfig
24 import androidx.paging.PagingData
25 import androidx.paging.cachedIn
26 import com.android.photopicker.core.banners.BannerDefinitions
27 import com.android.photopicker.core.banners.BannerManager
28 import com.android.photopicker.core.components.MediaGridItem
29 import com.android.photopicker.core.events.Event
30 import com.android.photopicker.core.events.Events
31 import com.android.photopicker.core.events.Telemetry
32 import com.android.photopicker.core.features.FeatureToken.PHOTO_GRID
33 import com.android.photopicker.core.selection.Selection
34 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED
35 import com.android.photopicker.data.DataService
36 import com.android.photopicker.data.model.Media
37 import com.android.photopicker.extensions.insertMonthSeparators
38 import com.android.photopicker.extensions.toMediaGridItemFromMedia
39 import dagger.hilt.android.lifecycle.HiltViewModel
40 import javax.inject.Inject
41 import kotlinx.coroutines.CoroutineScope
42 import kotlinx.coroutines.flow.Flow
43 import kotlinx.coroutines.launch
44 
45 /**
46  * The view model for the primary Photo grid.
47  *
48  * This view model collects the data from [DataService] and caches it in its scope so that loaded
49  * data is saved between navigations so that the composable can maintain list positions when
50  * navigating back and forth between routes.
51  */
52 @HiltViewModel
53 class PhotoGridViewModel
54 @Inject
55 constructor(
56     private val scopeOverride: CoroutineScope?,
57     private val selection: Selection<Media>,
58     private val dataService: DataService,
59     private val events: Events,
60     private val bannerManager: BannerManager,
61 ) : ViewModel() {
62 
63     companion object {
64         val TAG: String = "PhotoGridViewModel"
65     }
66 
67     // Check if a scope override was injected before using the default [viewModelScope]
68     private val scope: CoroutineScope =
69         if (scopeOverride == null) {
70             this.viewModelScope
71         } else {
72             scopeOverride
73         }
74 
75     // Request Media in batches of 50 items
76     private val PHOTO_GRID_PAGE_SIZE = 50
77 
78     // The size of the initial load when no pages are loaded. Ensures there is enough content
79     // to cover small scrolls.
80     private val PHOTO_GRID_INITIAL_LOAD_SIZE = PHOTO_GRID_PAGE_SIZE * 3
81 
82     // How far from the edge of loaded content before fetching the next page
83     private val PHOTO_GRID_PREFETCH_DISTANCE = PHOTO_GRID_PAGE_SIZE * 2
84 
85     // Keep up to 10 pages loaded in memory before unloading pages.
86     private val PHOTO_GRID_MAX_ITEMS_IN_MEMORY = PHOTO_GRID_PAGE_SIZE * 10
87 
88     val pagingConfig =
89         PagingConfig(
90             pageSize = PHOTO_GRID_PAGE_SIZE,
91             maxSize = PHOTO_GRID_MAX_ITEMS_IN_MEMORY,
92             initialLoadSize = PHOTO_GRID_INITIAL_LOAD_SIZE,
93             prefetchDistance = PHOTO_GRID_PREFETCH_DISTANCE,
94         )
95 
96     val pager =
97         Pager(
98             PagingConfig(pageSize = PHOTO_GRID_PAGE_SIZE, maxSize = PHOTO_GRID_MAX_ITEMS_IN_MEMORY)
<lambda>null99         ) {
100             dataService.mediaPagingSource()
101         }
102 
103     /**
104      * If initialized, it contains a cold flow of [PagingData] that can be displayed on the
105      * [PhotoGrid]. Otherwise, this points to null. See [getData] for initializing this flow.
106      */
107     private var _data: Flow<PagingData<MediaGridItem>>? = null
108 
109     /**
110      * If initialized, it contains the last known recent section's cell count. The count can change
111      * when the [MainActivity] or the [PhotoGrid] is recreated. See [getData] for initializing this
112      * flow.
113      */
114     private var _recentsCellCount: Int? = null
115 
116     /**
117      * Export paging data from the pager and prepare it for use in the [MediaGrid]. Also cache the
118      * [_data] and [_recentsCellCount] for reuse if the activity gets recreated.
119      */
getDatanull120     fun getData(recentsCellCount: Int): Flow<PagingData<MediaGridItem>> {
121         return if (
122             _recentsCellCount != null && _recentsCellCount!! == recentsCellCount && _data != null
123         ) {
124             Log.d(
125                 TAG,
126                 "Media grid data flow is already initialized with the correct recents " +
127                     "cell count: " +
128                     recentsCellCount
129             )
130             _data!!
131         } else {
132             Log.d(
133                 TAG,
134                 "Media grid data flow is not initialized with the correct recents " +
135                     "cell count" +
136                     recentsCellCount
137             )
138             _recentsCellCount = recentsCellCount
139             val data: Flow<PagingData<MediaGridItem>> =
140                 pager.flow
141                     .toMediaGridItemFromMedia()
142                     .insertMonthSeparators(recentsCellCount)
143                     // After the load and transformations, cache the data in the viewModelScope.
144                     // This ensures that the list position and state will be remembered by the
145                     // MediaGrid
146                     // when navigating back to the PhotoGrid route.
147                     .cachedIn(scope)
148             _data = data
149             data
150         }
151     }
152 
153     /** Export the [Banner] flow from BannerManager to the UI */
154     val banners = bannerManager.flow
155 
156     /**
157      * Dismissal handler from the UI to mark a particular banner as dismissed by the user. This call
158      * is handed off to the bannerManager to persist any relevant dismissal state.
159      *
160      * Afterwards, refreshBanners is called to check for any new Banners from [BannerManager].
161      */
markBannerAsDismissednull162     fun markBannerAsDismissed(banner: BannerDefinitions) {
163         scope.launch {
164             bannerManager.markBannerAsDismissed(banner)
165             bannerManager.refreshBanners()
166         }
167     }
168 
169     /**
170      * Click handler that is called when items in the grid are clicked. Selection updates are made
171      * in the viewModelScope to ensure they aren't canceled if the user navigates away from the
172      * PhotoGrid composable.
173      */
handleGridItemSelectionnull174     fun handleGridItemSelection(
175         item: Media,
176         selectionLimitExceededMessage: String,
177     ) {
178         // Update the selectable values in the received media object.
179         val updatedMediaItem =
180             Media.withSelectable(
181                 item, /* selectionSource */
182                 Telemetry.MediaLocation.MAIN_GRID, /* album */
183                 null
184             )
185         scope.launch {
186             val result = selection.toggle(updatedMediaItem)
187             if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) {
188                 scope.launch {
189                     events.dispatch(
190                         Event.ShowSnackbarMessage(PHOTO_GRID.token, selectionLimitExceededMessage)
191                     )
192                 }
193             }
194         }
195     }
196 }
197