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