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; 18 19 import static android.database.DatabaseUtils.dumpCursorToString; 20 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALL_PROJECTION; 21 import static android.provider.CloudMediaProviderContract.AlbumColumns.AUTHORITY; 22 import static android.provider.CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO; 23 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT; 24 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_NAME; 25 26 import static com.android.providers.media.PickerUriResolver.getAlbumUri; 27 import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri; 28 29 import static java.util.Objects.requireNonNull; 30 31 import android.content.Context; 32 import android.content.Intent; 33 import android.database.Cursor; 34 import android.database.CursorWrapper; 35 import android.database.MergeCursor; 36 import android.os.Bundle; 37 import android.os.Trace; 38 import android.provider.MediaStore; 39 import android.text.TextUtils; 40 import android.util.Log; 41 42 import androidx.annotation.NonNull; 43 import androidx.annotation.Nullable; 44 45 import com.android.providers.media.photopicker.data.CloudProviderQueryExtras; 46 import com.android.providers.media.photopicker.data.PickerDbFacade; 47 48 import java.util.ArrayList; 49 import java.util.Arrays; 50 import java.util.HashMap; 51 import java.util.List; 52 import java.util.Map; 53 54 /** 55 * Fetches data for the picker UI from the db and cloud/local providers 56 */ 57 public class PickerDataLayer { 58 private static final String TAG = "PickerDataLayer"; 59 private static final boolean DEBUG = false; 60 private static final boolean DEBUG_DUMP_CURSORS = false; 61 62 public static final String QUERY_ARG_LOCAL_ONLY = "android:query-arg-local-only"; 63 64 private final Context mContext; 65 private final PickerDbFacade mDbFacade; 66 private final PickerSyncController mSyncController; 67 private final String mLocalProvider; 68 PickerDataLayer(Context context, PickerDbFacade dbFacade, PickerSyncController syncController)69 public PickerDataLayer(Context context, PickerDbFacade dbFacade, 70 PickerSyncController syncController) { 71 mContext = context; 72 mDbFacade = dbFacade; 73 mSyncController = syncController; 74 mLocalProvider = dbFacade.getLocalProvider(); 75 } 76 77 /** 78 * Returns {@link Cursor} with all local media part of the given album in {@code queryArgs} 79 */ fetchLocalMedia(Bundle queryArgs)80 public Cursor fetchLocalMedia(Bundle queryArgs) { 81 queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true); 82 return fetchMediaInternal(queryArgs); 83 } 84 85 /** 86 * Returns {@link Cursor} with all local+cloud media part of the given album in 87 * {@code queryArgs} 88 */ fetchAllMedia(Bundle queryArgs)89 public Cursor fetchAllMedia(Bundle queryArgs) { 90 queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false); 91 return fetchMediaInternal(queryArgs); 92 } 93 fetchMediaInternal(Bundle queryArgs)94 private Cursor fetchMediaInternal(Bundle queryArgs) { 95 if (DEBUG) { 96 Log.d(TAG, "fetchMediaInternal() " 97 + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL") 98 + " args=" + queryArgs); 99 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 100 } 101 102 final CloudProviderQueryExtras queryExtras = 103 CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs); 104 final String albumAuthority = queryExtras.getAlbumAuthority(); 105 106 Trace.beginSection(traceSectionName("fetchMediaInternal", albumAuthority)); 107 108 Cursor result = null; 109 try { 110 final boolean isLocalOnly = queryExtras.isLocalOnly(); 111 final String albumId = queryExtras.getAlbumId(); 112 // Use media table for all media except albums. Merged categories like, 113 // favorites and video are tagged in the media table and are not a part of 114 // album_media. 115 if (TextUtils.isEmpty(albumId) || isMergedAlbum(queryExtras)) { 116 // Refresh the 'media' table 117 syncAllMedia(isLocalOnly); 118 119 if (!isLocalOnly && TextUtils.isEmpty(albumId)) { 120 // TODO(b/257887919): Build proper UI and remove this. 121 // Notify that the picker is launched in case there's any pending UI 122 // notification 123 mSyncController.notifyPickerLaunch(); 124 } 125 126 // Fetch all merged and deduped cloud and local media from 'media' table 127 // This also matches 'merged' albums like Favorites because |authority| will 128 // be null, hence we have to fetch the data from the picker db 129 result = mDbFacade.queryMediaForUi(queryExtras.toQueryFilter()); 130 } else { 131 if (isLocalOnly && !isLocal(albumAuthority)) { 132 // This is error condition because when cloud content is disabled, we shouldn't 133 // send any cloud albums in available albums list. 134 throw new IllegalStateException( 135 "Can't exclude cloud contents in cloud album " + albumAuthority); 136 } 137 138 // The album type here can only be local or cloud because merged categories like, 139 // Favorites and Videos would hit the first condition. 140 // Refresh the 'album_media' table 141 mSyncController.syncAlbumMedia(albumId, isLocal(albumAuthority)); 142 143 // Fetch album specific media for local or cloud from 'album_media' table 144 result = mDbFacade.queryAlbumMediaForUi( 145 queryExtras.toQueryFilter(), albumAuthority); 146 } 147 return result; 148 } finally { 149 Trace.endSection(); 150 if (DEBUG) { 151 if (result == null) { 152 Log.d(TAG, "fetchMediaInternal()'s result is null"); 153 } else { 154 Log.d(TAG, "fetchMediaInternal() loaded " + result.getCount() + " items"); 155 if (DEBUG_DUMP_CURSORS) { 156 Log.v(TAG, dumpCursorToString(result)); 157 } 158 } 159 } 160 } 161 } 162 syncAllMedia(boolean isLocalOnly)163 private void syncAllMedia(boolean isLocalOnly) { 164 if (isLocalOnly) { 165 mSyncController.syncAllMediaFromLocalProvider(); 166 } else { 167 mSyncController.syncAllMedia(); 168 } 169 } 170 171 /** 172 * Checks if the query is for a merged album type. 173 * Some albums are not cloud only, they are merged from files on devices and the cloudprovider. 174 */ isMergedAlbum(CloudProviderQueryExtras queryExtras)175 private boolean isMergedAlbum(CloudProviderQueryExtras queryExtras) { 176 final boolean isFavorite = queryExtras.isFavorite(); 177 final boolean isVideo = queryExtras.isVideo(); 178 return isFavorite || isVideo; 179 } 180 181 /** 182 * Returns {@link Cursor} with all local and merged albums with local items. 183 */ fetchLocalAlbums(Bundle queryArgs)184 public Cursor fetchLocalAlbums(Bundle queryArgs) { 185 queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true); 186 return fetchAlbumsInternal(queryArgs); 187 } 188 189 /** 190 * Returns {@link Cursor} with all local, merged and cloud albums 191 */ fetchAllAlbums(Bundle queryArgs)192 public Cursor fetchAllAlbums(Bundle queryArgs) { 193 queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false); 194 return fetchAlbumsInternal(queryArgs); 195 } 196 fetchAlbumsInternal(Bundle queryArgs)197 private Cursor fetchAlbumsInternal(Bundle queryArgs) { 198 if (DEBUG) { 199 Log.d(TAG, "fetchAlbums() " 200 + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL") 201 + " args=" + queryArgs); 202 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 203 } 204 205 Trace.beginSection(traceSectionName("fetchAlbums")); 206 207 Cursor result = null; 208 try { 209 final boolean isLocalOnly = queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY, false); 210 // Refresh the 'media' table so that 'merged' albums (Favorites and Videos) are 211 // up-to-date 212 syncAllMedia(isLocalOnly); 213 214 final String cloudProvider = mDbFacade.getCloudProvider(); 215 final CloudProviderQueryExtras queryExtras = 216 CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs); 217 final Bundle cloudMediaArgs = queryExtras.toCloudMediaBundle(); 218 final List<Cursor> cursors = new ArrayList<>(); 219 final Bundle cursorExtra = new Bundle(); 220 cursorExtra.putString(MediaStore.EXTRA_CLOUD_PROVIDER, cloudProvider); 221 cursorExtra.putString(MediaStore.EXTRA_LOCAL_PROVIDER, mLocalProvider); 222 223 // Favorites and Videos are merged albums. 224 final Cursor mergedAlbums = mDbFacade.getMergedAlbums(queryExtras.toQueryFilter()); 225 if (mergedAlbums != null) { 226 cursors.add(mergedAlbums); 227 } 228 229 final Cursor localAlbums = queryProviderAlbums(mLocalProvider, cloudMediaArgs); 230 if (localAlbums != null) { 231 cursors.add(new AlbumsCursorWrapper(localAlbums, mLocalProvider)); 232 } 233 234 if (!isLocalOnly) { 235 final Cursor cloudAlbums = queryProviderAlbums(cloudProvider, cloudMediaArgs); 236 if (cloudAlbums != null) { 237 // There's a bug in the Merge Cursor code (b/241096151) such that if the cursors 238 // being merged have different projections, the data gets corrupted post IPC. 239 // Fixing this bug requires a dessert release and will not be compatible with 240 // android T-. Hence, we're using {@link AlbumsCursorWrapper} that unifies the 241 // local and cloud album cursors' projections to {@link ALL_PROJECTION} 242 cursors.add(new AlbumsCursorWrapper(cloudAlbums, cloudProvider)); 243 } 244 } 245 246 if (cursors.isEmpty()) { 247 return null; 248 } 249 250 result = new MergeCursor(cursors.toArray(new Cursor[cursors.size()])); 251 result.setExtras(cursorExtra); 252 return result; 253 } finally { 254 Trace.endSection(); 255 if (DEBUG) { 256 if (result == null) { 257 Log.d(TAG, "fetchAlbumsInternal()'s result is null"); 258 } else { 259 Log.d(TAG, "fetchAlbumsInternal() loaded " + result.getCount() + " items"); 260 if (DEBUG_DUMP_CURSORS) { 261 Log.v(TAG, dumpCursorToString(result)); 262 } 263 } 264 } 265 } 266 } 267 268 @Nullable fetchCloudAccountInfo()269 public AccountInfo fetchCloudAccountInfo() { 270 if (DEBUG) { 271 Log.d(TAG, "fetchCloudAccountInfo()"); 272 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 273 } 274 275 final String cloudProvider = mDbFacade.getCloudProvider(); 276 if (cloudProvider == null) { 277 return null; 278 } 279 280 Trace.beginSection(traceSectionName("fetchCloudAccountInfo")); 281 try { 282 return fetchCloudAccountInfoInternal(cloudProvider); 283 } catch (Exception e) { 284 Log.w(TAG, "Failed to fetch account info from cloud provider: " + cloudProvider, e); 285 return null; 286 } finally { 287 Trace.endSection(); 288 } 289 } 290 291 @Nullable fetchCloudAccountInfoInternal(@onNull String cloudProvider)292 private AccountInfo fetchCloudAccountInfoInternal(@NonNull String cloudProvider) { 293 final Bundle accountBundle = mContext.getContentResolver() 294 .call(getMediaCollectionInfoUri(cloudProvider), METHOD_GET_MEDIA_COLLECTION_INFO, 295 /* arg */ null, /* extras */ null); 296 297 final String accountName = accountBundle.getString(ACCOUNT_NAME); 298 if (accountName == null) { 299 return null; 300 } 301 final Intent configIntent = accountBundle.getParcelable(ACCOUNT_CONFIGURATION_INTENT); 302 303 return new AccountInfo(accountName, configIntent); 304 } 305 queryProviderAlbums(@ullable String authority, Bundle queryArgs)306 private Cursor queryProviderAlbums(@Nullable String authority, Bundle queryArgs) { 307 if (authority == null) { 308 // Can happen if there is no cloud provider 309 return null; 310 } 311 312 Trace.beginSection(traceSectionName("queryProviderAlbums", authority)); 313 try { 314 return queryProviderAlbumsInternal(authority, queryArgs); 315 } finally { 316 Trace.endSection(); 317 } 318 } 319 queryProviderAlbumsInternal(@onNull String authority, Bundle queryArgs)320 private Cursor queryProviderAlbumsInternal(@NonNull String authority, Bundle queryArgs) { 321 try { 322 return mContext.getContentResolver().query(getAlbumUri(authority), 323 /* projection */ null, queryArgs, /* cancellationSignal */ null); 324 } catch (Exception e) { 325 Log.w(TAG, "Failed to fetch cloud albums for: " + authority, e); 326 return null; 327 } 328 } 329 isLocal(String authority)330 private boolean isLocal(String authority) { 331 return mLocalProvider.equals(authority); 332 } 333 traceSectionName(@onNull String method)334 private String traceSectionName(@NonNull String method) { 335 return traceSectionName(method, null); 336 } 337 traceSectionName(@onNull String method, @Nullable String authority)338 private String traceSectionName(@NonNull String method, @Nullable String authority) { 339 final StringBuilder sb = new StringBuilder("PDL.") 340 .append(method); 341 if (authority != null) { 342 sb.append('[').append(isLocal(authority) ? "local" : "cloud").append(']'); 343 } 344 return sb.toString(); 345 } 346 347 public static class AccountInfo { 348 public final String accountName; 349 public final Intent accountConfigurationIntent; 350 AccountInfo(String accountName, Intent accountConfigurationIntent)351 public AccountInfo(String accountName, Intent accountConfigurationIntent) { 352 this.accountName = accountName; 353 this.accountConfigurationIntent = accountConfigurationIntent; 354 } 355 } 356 357 /** 358 * A {@link CursorWrapper} that exposes the data stored in the underlying {@link Cursor} in the 359 * {@link ALL_PROJECTION} "format", additionally overriding the {@link AUTHORITY} column. 360 * Columns from the underlying that are not in the {@link ALL_PROJECTION} are ignored. 361 * Missing columns (except {@link AUTHORITY}) are set with default value of {@code null}. 362 */ 363 private static class AlbumsCursorWrapper extends CursorWrapper { 364 static final String TAG = "AlbumsCursorWrapper"; 365 366 @NonNull static final Map<String, Integer> COLUMN_NAME_TO_INDEX_MAP; 367 static final int AUTHORITY_COLUMN_INDEX; 368 static { 369 final Map<String, Integer> map = new HashMap<>(); 370 for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) { map.put(ALL_PROJECTION[columnIndex], columnIndex)371 map.put(ALL_PROJECTION[columnIndex], columnIndex); 372 } 373 COLUMN_NAME_TO_INDEX_MAP = map; 374 AUTHORITY_COLUMN_INDEX = map.get(AUTHORITY); 375 } 376 377 @NonNull final String mAuthority; 378 @NonNull final int[] mColumnIndexToCursorColumnIndexArray; 379 380 boolean mAuthorityMismatchLogged = false; 381 AlbumsCursorWrapper(@onNull Cursor cursor, @NonNull String authority)382 AlbumsCursorWrapper(@NonNull Cursor cursor, @NonNull String authority) { 383 super(requireNonNull(cursor)); 384 mAuthority = requireNonNull(authority); 385 386 mColumnIndexToCursorColumnIndexArray = new int[ALL_PROJECTION.length]; 387 for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) { 388 final String columnName = ALL_PROJECTION[columnIndex]; 389 final int cursorColumnIndex = cursor.getColumnIndex(columnName); 390 mColumnIndexToCursorColumnIndexArray[columnIndex] = cursorColumnIndex; 391 } 392 } 393 394 @Override getColumnCount()395 public int getColumnCount() { 396 return ALL_PROJECTION.length; 397 } 398 399 @Override getColumnIndex(String columnName)400 public int getColumnIndex(String columnName) { 401 return COLUMN_NAME_TO_INDEX_MAP.get(columnName); 402 } 403 404 @Override getColumnIndexOrThrow(String columnName)405 public int getColumnIndexOrThrow(String columnName) 406 throws IllegalArgumentException { 407 final int columnIndex = getColumnIndex(columnName); 408 if (columnIndex < 0) { 409 throw new IllegalArgumentException("column '" + columnName 410 + "' does not exist. Available columns: " 411 + Arrays.toString(getColumnNames())); 412 } 413 return columnIndex; 414 } 415 416 @Override getColumnName(int columnIndex)417 public String getColumnName(int columnIndex) { 418 return ALL_PROJECTION[columnIndex]; 419 } 420 421 @Override getColumnNames()422 public String[] getColumnNames() { 423 return ALL_PROJECTION; 424 } 425 426 @Override getString(int columnIndex)427 public String getString(int columnIndex) { 428 // 1. Get value from the underlying cursor. 429 final int cursorColumnIndex = mColumnIndexToCursorColumnIndexArray[columnIndex]; 430 final String cursorValue = cursorColumnIndex != -1 431 ? getWrappedCursor().getString(cursorColumnIndex) : null; 432 433 // 2a. If this is NOT the AUTHORITY column: just return the value. 434 if (columnIndex != AUTHORITY_COLUMN_INDEX) { 435 return cursorValue; 436 } 437 438 // Validity check: the cursor's authority value, if present, is expected to match the 439 // mAuthority. Don't throw though, just log (at WARN). Also, only log once for the 440 // cursor (we don't need 10,000 of these lines in the log). 441 if (!mAuthorityMismatchLogged 442 && cursorValue != null && !cursorValue.equals(mAuthority)) { 443 Log.w(TAG, "Cursor authority - '" + cursorValue + "' - is different from the " 444 + "expected authority '" + mAuthority + "'"); 445 mAuthorityMismatchLogged = true; 446 } 447 448 // 2b. If this IS the AUTHORITY column: "override" whatever value (which may be null) 449 // is stored in the cursor. 450 return mAuthority; 451 } 452 } 453 } 454