1 /* 2 * Copyright (C) 2021 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.providers.media.photopicker.viewmodel; 18 19 import static com.android.providers.media.util.MimeUtils.isImageMimeType; 20 import static com.android.providers.media.util.MimeUtils.isVideoMimeType; 21 22 import android.app.Application; 23 import android.content.Context; 24 import android.content.Intent; 25 import android.database.Cursor; 26 import android.os.Bundle; 27 import android.os.Parcelable; 28 import android.text.TextUtils; 29 import android.util.Log; 30 31 import androidx.annotation.NonNull; 32 import androidx.annotation.Nullable; 33 import androidx.annotation.VisibleForTesting; 34 import androidx.lifecycle.AndroidViewModel; 35 import androidx.lifecycle.LiveData; 36 import androidx.lifecycle.MutableLiveData; 37 38 import com.android.internal.logging.InstanceId; 39 import com.android.internal.logging.InstanceIdSequence; 40 import com.android.providers.media.photopicker.data.ItemsProvider; 41 import com.android.providers.media.photopicker.data.MuteStatus; 42 import com.android.providers.media.photopicker.data.Selection; 43 import com.android.providers.media.photopicker.data.UserIdManager; 44 import com.android.providers.media.photopicker.data.model.Category; 45 import com.android.providers.media.photopicker.data.model.Item; 46 import com.android.providers.media.photopicker.data.model.UserId; 47 import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; 48 import com.android.providers.media.photopicker.util.DateTimeUtils; 49 import com.android.providers.media.util.ForegroundThread; 50 51 import java.util.ArrayList; 52 import java.util.List; 53 54 /** 55 * PickerViewModel to store and handle data for PhotoPickerActivity. 56 */ 57 public class PickerViewModel extends AndroidViewModel { 58 public static final String TAG = "PhotoPicker"; 59 60 private static final int RECENT_MINIMUM_COUNT = 12; 61 62 private static final int INSTANCE_ID_MAX = 1 << 15; 63 64 private final Selection mSelection; 65 private final MuteStatus mMuteStatus; 66 67 // TODO(b/193857982): We keep these four data sets now, we may need to find a way to reduce the 68 // data set to reduce memories. 69 // The list of Items with all photos and videos 70 private MutableLiveData<List<Item>> mItemList; 71 // The list of Items with all photos and videos in category 72 private MutableLiveData<List<Item>> mCategoryItemList; 73 // The list of categories. 74 private MutableLiveData<List<Category>> mCategoryList; 75 76 private ItemsProvider mItemsProvider; 77 private UserIdManager mUserIdManager; 78 79 private InstanceId mInstanceId; 80 private PhotoPickerUiEventLogger mLogger; 81 82 private String mMimeTypeFilter = null; 83 private int mBottomSheetState; 84 85 private Category mCurrentCategory; 86 PickerViewModel(@onNull Application application)87 public PickerViewModel(@NonNull Application application) { 88 super(application); 89 final Context context = application.getApplicationContext(); 90 mItemsProvider = new ItemsProvider(context); 91 mSelection = new Selection(); 92 mUserIdManager = UserIdManager.create(context); 93 mMuteStatus = new MuteStatus(); 94 mInstanceId = new InstanceIdSequence(INSTANCE_ID_MAX).newInstanceId(); 95 mLogger = new PhotoPickerUiEventLogger(); 96 } 97 98 @VisibleForTesting setItemsProvider(@onNull ItemsProvider itemsProvider)99 public void setItemsProvider(@NonNull ItemsProvider itemsProvider) { 100 mItemsProvider = itemsProvider; 101 } 102 103 @VisibleForTesting setUserIdManager(@onNull UserIdManager userIdManager)104 public void setUserIdManager(@NonNull UserIdManager userIdManager) { 105 mUserIdManager = userIdManager; 106 } 107 108 /** 109 * @return {@link UserIdManager} for this context. 110 */ getUserIdManager()111 public UserIdManager getUserIdManager() { 112 return mUserIdManager; 113 } 114 115 /** 116 * @return {@code mSelection} that manages the selection 117 */ getSelection()118 public Selection getSelection() { 119 return mSelection; 120 } 121 122 123 /** 124 * @return {@code mMuteStatus} that tracks the volume mute status of the video preview 125 */ getMuteStatus()126 public MuteStatus getMuteStatus() { 127 return mMuteStatus; 128 } 129 130 /** 131 * Reset to personal profile mode. 132 */ resetToPersonalProfile()133 public void resetToPersonalProfile() { 134 // 1. Clear Selected items 135 mSelection.clearSelectedItems(); 136 // 2. Change profile to personal user 137 mUserIdManager.setPersonalAsCurrentUserProfile(); 138 // 3. Update Item and Category lists 139 updateItems(); 140 updateCategories(); 141 } 142 143 /** 144 * @return the list of Items with all photos and videos {@link #mItemList} on the device. 145 */ getItems()146 public LiveData<List<Item>> getItems() { 147 if (mItemList == null) { 148 updateItems(); 149 } 150 return mItemList; 151 } 152 loadItems(Category category, UserId userId)153 private List<Item> loadItems(Category category, UserId userId) { 154 final List<Item> items = new ArrayList<>(); 155 156 try (Cursor cursor = mItemsProvider.getItems(category, /* offset */ 0, 157 /* limit */ -1, mMimeTypeFilter, userId)) { 158 if (cursor == null || cursor.getCount() == 0) { 159 Log.d(TAG, "Didn't receive any items for " + category 160 + ", either cursor is null or cursor count is zero"); 161 return items; 162 } 163 164 // We only add the RECENT header on the PhotosTabFragment with CATEGORY_DEFAULT. In this 165 // case, we call this method {loadItems} with null category. When the category is not 166 // empty, we don't show the RECENT header. 167 final boolean showRecent = category.isDefault(); 168 169 int recentSize = 0; 170 long currentDateTaken = 0; 171 172 if (showRecent) { 173 // add Recent date header 174 items.add(Item.createDateItem(0)); 175 } 176 while (cursor.moveToNext()) { 177 // TODO(b/188394433): Return userId in the cursor so that we do not need to pass it 178 // here again. 179 final Item item = Item.fromCursor(cursor, userId); 180 final long dateTaken = item.getDateTaken(); 181 // the minimum count of items in recent is not reached 182 if (showRecent && recentSize < RECENT_MINIMUM_COUNT) { 183 recentSize++; 184 currentDateTaken = dateTaken; 185 } 186 187 // The date taken of these two images are not on the 188 // same day, add the new date header. 189 if (!DateTimeUtils.isSameDate(currentDateTaken, dateTaken)) { 190 items.add(Item.createDateItem(dateTaken)); 191 currentDateTaken = dateTaken; 192 } 193 items.add(item); 194 } 195 } 196 197 Log.d(TAG, "Loaded " + items.size() + " items in " + category + " for user " 198 + userId.toString()); 199 return items; 200 } 201 loadItemsAsync()202 private void loadItemsAsync() { 203 final UserId userId = mUserIdManager.getCurrentUserProfileId(); 204 ForegroundThread.getExecutor().execute(() -> { 205 mItemList.postValue(loadItems(Category.DEFAULT, userId)); 206 }); 207 } 208 209 /** 210 * Update the item List {@link #mItemList} 211 */ updateItems()212 public void updateItems() { 213 if (mItemList == null) { 214 mItemList = new MutableLiveData<>(); 215 } 216 loadItemsAsync(); 217 } 218 219 /** 220 * Get the list of all photos and videos with the specific {@code category} on the device. 221 * 222 * In our use case, we only keep the list of current category {@link #mCurrentCategory} in 223 * {@link #mCategoryItemList}. If the {@code category} and {@link #mCurrentCategory} are 224 * different, we will create the new LiveData to {@link #mCategoryItemList}. 225 * 226 * @param category the category we want to be queried 227 * @return the list of all photos and videos with the specific {@code category} 228 * {@link #mCategoryItemList} 229 */ getCategoryItems(@onNull Category category)230 public LiveData<List<Item>> getCategoryItems(@NonNull Category category) { 231 if (mCategoryItemList == null || !TextUtils.equals(mCurrentCategory.getId(), 232 category.getId())) { 233 mCategoryItemList = new MutableLiveData<>(); 234 mCurrentCategory = category; 235 } 236 updateCategoryItems(); 237 return mCategoryItemList; 238 } 239 loadCategoryItemsAsync()240 private void loadCategoryItemsAsync() { 241 final UserId userId = mUserIdManager.getCurrentUserProfileId(); 242 ForegroundThread.getExecutor().execute(() -> { 243 mCategoryItemList.postValue(loadItems(mCurrentCategory, userId)); 244 }); 245 } 246 247 /** 248 * Update the item List with the {@link #mCurrentCategory} {@link #mCategoryItemList} 249 * 250 * @throws IllegalStateException category and category items is not initiated before calling 251 * this method 252 */ 253 @VisibleForTesting updateCategoryItems()254 public void updateCategoryItems() { 255 if (mCategoryItemList == null || mCurrentCategory == null) { 256 throw new IllegalStateException("mCurrentCategory and mCategoryItemList are not" 257 + " initiated. Please call getCategoryItems before calling this method"); 258 } 259 loadCategoryItemsAsync(); 260 } 261 262 /** 263 * @return the list of Categories {@link #mCategoryList} 264 */ getCategories()265 public LiveData<List<Category>> getCategories() { 266 if (mCategoryList == null) { 267 updateCategories(); 268 } 269 return mCategoryList; 270 } 271 loadCategories(UserId userId)272 private List<Category> loadCategories(UserId userId) { 273 final List<Category> categoryList = new ArrayList<>(); 274 try (final Cursor cursor = mItemsProvider.getCategories(mMimeTypeFilter, userId)) { 275 if (cursor == null || cursor.getCount() == 0) { 276 Log.d(TAG, "Didn't receive any categories, either cursor is null or" 277 + " cursor count is zero"); 278 return categoryList; 279 } 280 281 while (cursor.moveToNext()) { 282 final Category category = Category.fromCursor(cursor, userId); 283 categoryList.add(category); 284 } 285 286 Log.d(TAG, 287 "Loaded " + categoryList.size() + " categories for user " + userId.toString()); 288 } 289 return categoryList; 290 } 291 loadCategoriesAsync()292 private void loadCategoriesAsync() { 293 final UserId userId = mUserIdManager.getCurrentUserProfileId(); 294 ForegroundThread.getExecutor().execute(() -> { 295 mCategoryList.postValue(loadCategories(userId)); 296 }); 297 } 298 299 /** 300 * Update the category List {@link #mCategoryList} 301 */ updateCategories()302 public void updateCategories() { 303 if (mCategoryList == null) { 304 mCategoryList = new MutableLiveData<>(); 305 } 306 loadCategoriesAsync(); 307 } 308 309 /** 310 * Return whether the {@link #mMimeTypeFilter} is {@code null} or not 311 */ hasMimeTypeFilter()312 public boolean hasMimeTypeFilter() { 313 return !TextUtils.isEmpty(mMimeTypeFilter); 314 } 315 316 /** 317 * Parse values from {@code intent} and set corresponding fields 318 */ parseValuesFromIntent(Intent intent)319 public void parseValuesFromIntent(Intent intent) throws IllegalArgumentException { 320 mUserIdManager.setIntentAndCheckRestrictions(intent); 321 322 final String mimeType = intent.getType(); 323 if (isMimeTypeMedia(mimeType)) { 324 mMimeTypeFilter = mimeType; 325 } 326 327 mSelection.parseSelectionValuesFromIntent(intent); 328 } 329 isMimeTypeMedia(@ullable String mimeType)330 private static boolean isMimeTypeMedia(@Nullable String mimeType) { 331 return isImageMimeType(mimeType) || isVideoMimeType(mimeType); 332 } 333 334 /** 335 * Set BottomSheet state 336 */ setBottomSheetState(int state)337 public void setBottomSheetState(int state) { 338 mBottomSheetState = state; 339 } 340 341 /** 342 * @return BottomSheet state 343 */ getBottomSheetState()344 public int getBottomSheetState() { 345 return mBottomSheetState; 346 } 347 logPickerOpened(String callingPackage)348 public void logPickerOpened(String callingPackage) { 349 if (getUserIdManager().isManagedUserSelected()) { 350 mLogger.logPickerOpenWork(mInstanceId, callingPackage); 351 } else { 352 mLogger.logPickerOpenPersonal(mInstanceId, callingPackage); 353 } 354 } 355 getInstanceId()356 public InstanceId getInstanceId() { 357 return mInstanceId; 358 } 359 setInstanceId(InstanceId parcelable)360 public void setInstanceId(InstanceId parcelable) { 361 mInstanceId = parcelable; 362 } 363 } 364