1 /* 2 * Copyright (C) 2022 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 android.provider.MediaStore.getCurrentCloudProvider; 20 21 import static com.android.providers.media.photopicker.util.CloudProviderUtils.getAvailableCloudProviders; 22 import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaCollectionInfo; 23 import static com.android.providers.media.photopicker.util.CloudProviderUtils.getProviderLabelForUser; 24 25 import android.content.ContentResolver; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.PackageManager; 29 import android.os.Bundle; 30 import android.os.UserHandle; 31 import android.provider.CloudMediaProviderContract.MediaCollectionInfo; 32 import android.text.TextUtils; 33 import android.util.AtomicFile; 34 import android.util.Log; 35 36 import androidx.annotation.NonNull; 37 import androidx.annotation.Nullable; 38 import androidx.annotation.VisibleForTesting; 39 40 import com.android.providers.media.ConfigStore; 41 import com.android.providers.media.photopicker.data.model.UserId; 42 import com.android.providers.media.photopicker.util.ThreadUtils; 43 import com.android.providers.media.util.XmlUtils; 44 45 import java.io.File; 46 import java.io.FileInputStream; 47 import java.io.FileOutputStream; 48 import java.util.HashMap; 49 import java.util.Map; 50 import java.util.concurrent.ExecutionException; 51 import java.util.concurrent.TimeoutException; 52 53 /** 54 * Banner Controller to store and handle the banner data per user for 55 * {@link com.android.providers.media.photopicker.PhotoPickerActivity}. 56 */ 57 class BannerController { 58 private static final String TAG = "BannerController"; 59 private static final String DATA_MEDIA_DIRECTORY_PATH = "/data/media/"; 60 private static final String LAST_CLOUD_PROVIDER_DATA_FILE_PATH_IN_USER_MEDIA_DIR = 61 "/.transforms/picker/last_cloud_provider_info"; 62 /** 63 * {@link #mCloudProviderDataMap} key to the last fetched 64 * {@link android.provider.CloudMediaProvider} authority. 65 */ 66 private static final String AUTHORITY = "authority"; 67 /** 68 * {@link #mCloudProviderDataMap} key to the last fetched account name in the then fetched 69 * {@link android.provider.CloudMediaProvider}. 70 */ 71 private static final String ACCOUNT_NAME = "account_name"; 72 private static final long GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS = 100L; 73 74 private final Context mContext; 75 private final UserHandle mUserHandle; 76 private final ConfigStore mConfigStore; 77 78 /** 79 * {@link File} for persisting the last fetched {@link android.provider.CloudMediaProvider} 80 * data. 81 */ 82 private final File mLastCloudProviderDataFile; 83 84 /** 85 * Last fetched {@link android.provider.CloudMediaProvider} data. 86 */ 87 private final Map<String, String> mCloudProviderDataMap = new HashMap<>(); 88 89 // Label of the current cloud media provider 90 private String mCmpLabel; 91 92 // Account selection activity intent of the current cloud media provider 93 private Intent mChooseCloudMediaAccountActivityIntent; 94 95 // Boolean 'Choose App' banner visibility 96 private boolean mShowChooseAppBanner; 97 98 // Boolean 'Cloud Media Available' banner visibility 99 private boolean mShowCloudMediaAvailableBanner; 100 101 // Boolean 'Account Updated' banner visibility 102 private boolean mShowAccountUpdatedBanner; 103 104 // Boolean 'Choose Account' banner visibility 105 private boolean mShowChooseAccountBanner; 106 BannerController(@onNull Context context, @NonNull UserHandle userHandle, @NonNull ConfigStore configStore)107 BannerController(@NonNull Context context, @NonNull UserHandle userHandle, 108 @NonNull ConfigStore configStore) { 109 Log.d(TAG, "Constructing the BannerController for user " + userHandle.getIdentifier()); 110 mContext = context; 111 mUserHandle = userHandle; 112 mConfigStore = configStore; 113 114 final String lastCloudProviderDataFilePath = DATA_MEDIA_DIRECTORY_PATH 115 + userHandle.getIdentifier() + LAST_CLOUD_PROVIDER_DATA_FILE_PATH_IN_USER_MEDIA_DIR; 116 mLastCloudProviderDataFile = new File(lastCloudProviderDataFilePath); 117 loadCloudProviderInfo(); 118 119 initialise(); 120 } 121 122 /** 123 * Same as {@link #initialise()}, renamed for readability. 124 */ reset()125 void reset() { 126 Log.d(TAG, "Resetting the BannerController for user " + mUserHandle.getIdentifier()); 127 initialise(); 128 } 129 130 /** 131 * Initialise the banner controller data 132 * 133 * 0. Assert non-main thread. 134 * 1. Fetch the latest cloud provider info. 135 * 2. {@link #onChangeCloudMediaInfo(String, String)} with the newly fetched authority and 136 * account name. 137 * 138 * Note : This method is expected to be called only in a non-main thread since we shouldn't 139 * block the UI thread on the heavy Binder calls to fetch the cloud media provider info. 140 */ initialise()141 private void initialise() { 142 String cmpAuthority = null, cmpAccountName = null; 143 mCmpLabel = null; 144 mChooseCloudMediaAccountActivityIntent = null; 145 // TODO(b/245746037): Remove try-catch for the RuntimeException. 146 // Under the hood MediaStore.getCurrentCloudProvider() makes an IPC call to the primary 147 // MediaProvider process, where we currently perform a UID check (making sure that 148 // the call both sender and receiver belong to the same UID). 149 // This setup works for our "regular" PhotoPickerActivity (running in :PhotoPicker 150 // process), but does not work for our test applications (installed to a different 151 // UID), that provide a mock PhotoPickerActivity which will also run this code. 152 // SOLUTION: replace the UID check on the receiving end (in MediaProvider) with a 153 // check for MANAGE_CLOUD_MEDIA_PROVIDER permission. 154 try { 155 // 0. Assert non-main thread. 156 ThreadUtils.assertNonMainThread(); 157 158 // 1. Fetch the latest cloud provider info. 159 final ContentResolver contentResolver = 160 UserId.of(mUserHandle).getContentResolver(mContext); 161 cmpAuthority = getCurrentCloudProvider(contentResolver); 162 mCmpLabel = getProviderLabelForUser(mContext, mUserHandle, cmpAuthority); 163 final Bundle cloudMediaCollectionInfo = getCloudMediaCollectionInfo(contentResolver, 164 cmpAuthority, GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS); 165 if (cloudMediaCollectionInfo != null) { 166 cmpAccountName = cloudMediaCollectionInfo.getString( 167 MediaCollectionInfo.ACCOUNT_NAME); 168 mChooseCloudMediaAccountActivityIntent = cloudMediaCollectionInfo.getParcelable( 169 MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT); 170 } 171 172 // Not logging the account name due to privacy concerns 173 Log.d(TAG, "Current CloudMediaProvider authority: " + cmpAuthority + ", label: " 174 + mCmpLabel); 175 } catch (PackageManager.NameNotFoundException | RuntimeException | ExecutionException 176 | InterruptedException | TimeoutException e) { 177 Log.w(TAG, "Could not fetch the current CloudMediaProvider", e); 178 updateCloudProviderDataMap(cmpAuthority, cmpAccountName); 179 clearBanners(); 180 return; 181 } 182 183 onChangeCloudMediaInfo(cmpAuthority, cmpAccountName); 184 } 185 186 /** 187 * On Change Cloud Media Info 188 * 189 * @param cmpAuthority Current {@link android.provider.CloudMediaProvider} authority. 190 * @param cmpAccountName Current {@link android.provider.CloudMediaProvider} account name. 191 * 192 * 1. If the previous & new cloud provider infos are the same, No-op. 193 * 2. Reset should show banners. 194 * 3. Update the saved and cached cloud provider info with the latest info. 195 */ 196 @VisibleForTesting onChangeCloudMediaInfo(@ullable String cmpAuthority, @Nullable String cmpAccountName)197 void onChangeCloudMediaInfo(@Nullable String cmpAuthority, @Nullable String cmpAccountName) { 198 // 1. If the previous & new cloud provider infos are the same, No-op. 199 final String lastCmpAuthority = mCloudProviderDataMap.get(AUTHORITY); 200 final String lastCmpAccountName = mCloudProviderDataMap.get(ACCOUNT_NAME); 201 202 Log.d(TAG, "Last CloudMediaProvider authority: " + lastCmpAuthority); 203 204 if (TextUtils.equals(lastCmpAuthority, cmpAuthority) 205 && TextUtils.equals(lastCmpAccountName, cmpAccountName)) { 206 // no-op 207 return; 208 } 209 210 // 2. Update banner visibilities. 211 clearBanners(); 212 213 if (cmpAuthority == null) { 214 // mShowChooseAppBanner is true iff the new authority is null and the available cloud 215 // providers list is not empty. 216 mShowChooseAppBanner = areCloudProviderOptionsAvailable(); 217 } else if (cmpAccountName == null) { 218 // mShowChooseAccountBanner is true iff the new account name is null while the new 219 // authority is NOT null. 220 mShowChooseAccountBanner = true; 221 } else if (TextUtils.equals(lastCmpAuthority, cmpAuthority)) { 222 // mShowAccountUpdatedBanner is true iff the new authority AND account name are NOT null 223 // AND the authority is unchanged. 224 mShowAccountUpdatedBanner = true; 225 } else { 226 // mShowCloudMediaAvailableBanner is true iff the new authority AND account name are 227 // NOT null AND the authority has changed. 228 mShowCloudMediaAvailableBanner = true; 229 } 230 231 // 3. Update the saved and cached cloud provider info with the latest info. 232 persistCloudProviderInfo(cmpAuthority, cmpAccountName); 233 } 234 235 /** 236 * Clear all banners 237 * 238 * Reset all should show banner {@code boolean} values to {@code false}. 239 */ clearBanners()240 private void clearBanners() { 241 mShowChooseAppBanner = false; 242 mShowCloudMediaAvailableBanner = false; 243 mShowAccountUpdatedBanner = false; 244 mShowChooseAccountBanner = false; 245 } 246 247 @VisibleForTesting areCloudProviderOptionsAvailable()248 boolean areCloudProviderOptionsAvailable() { 249 return !getAvailableCloudProviders(mContext, mConfigStore, mUserHandle).isEmpty(); 250 } 251 252 /** 253 * @return the authority of the current {@link android.provider.CloudMediaProvider}. 254 */ 255 @Nullable getCloudMediaProviderAuthority()256 String getCloudMediaProviderAuthority() { 257 return mCloudProviderDataMap.get(AUTHORITY); 258 } 259 260 /** 261 * @return the label of the current {@link android.provider.CloudMediaProvider}. 262 */ 263 @Nullable getCloudMediaProviderLabel()264 String getCloudMediaProviderLabel() { 265 return mCmpLabel; 266 } 267 268 /** 269 * @return the account name of the current {@link android.provider.CloudMediaProvider}. 270 */ 271 @Nullable getCloudMediaProviderAccountName()272 String getCloudMediaProviderAccountName() { 273 return mCloudProviderDataMap.get(ACCOUNT_NAME); 274 } 275 276 /** 277 * @return the account selection activity {@link Intent} of the current 278 * {@link android.provider.CloudMediaProvider}. 279 */ 280 @Nullable getChooseCloudMediaAccountActivityIntent()281 Intent getChooseCloudMediaAccountActivityIntent() { 282 return mChooseCloudMediaAccountActivityIntent; 283 } 284 285 @VisibleForTesting setChooseCloudMediaAccountActivityIntent( @ullable Intent chooseCloudMediaAccountActivityIntent)286 void setChooseCloudMediaAccountActivityIntent( 287 @Nullable Intent chooseCloudMediaAccountActivityIntent) { 288 mChooseCloudMediaAccountActivityIntent = chooseCloudMediaAccountActivityIntent; 289 } 290 291 /** 292 * @return the 'Choose App' banner visibility {@link #mShowChooseAppBanner}. 293 */ shouldShowChooseAppBanner()294 boolean shouldShowChooseAppBanner() { 295 return mShowChooseAppBanner; 296 } 297 298 /** 299 * @return the 'Cloud Media Available' banner visibility 300 * {@link #mShowCloudMediaAvailableBanner}. 301 */ shouldShowCloudMediaAvailableBanner()302 boolean shouldShowCloudMediaAvailableBanner() { 303 return mShowCloudMediaAvailableBanner; 304 } 305 306 /** 307 * @return the 'Account Updated' banner visibility {@link #mShowAccountUpdatedBanner}. 308 */ shouldShowAccountUpdatedBanner()309 boolean shouldShowAccountUpdatedBanner() { 310 return mShowAccountUpdatedBanner; 311 } 312 313 /** 314 * @return the 'Choose Account' banner visibility {@link #mShowChooseAccountBanner}. 315 */ shouldShowChooseAccountBanner()316 boolean shouldShowChooseAccountBanner() { 317 return mShowChooseAccountBanner; 318 } 319 320 /** 321 * Dismiss (hide) the 'Choose App' banner 322 * 323 * Set the 'Choose App' banner visibility {@link #mShowChooseAppBanner} as {@code false}. 324 */ onUserDismissedChooseAppBanner()325 void onUserDismissedChooseAppBanner() { 326 if (!mShowChooseAppBanner) { 327 Log.d(TAG, "Choose app banner visibility for current user is false on dismiss"); 328 } else { 329 mShowChooseAppBanner = false; 330 } 331 } 332 333 /** 334 * Dismiss (hide) the 'Cloud Media Available' banner 335 * 336 * Set the 'Cloud Media Available' banner visibility {@link #mShowCloudMediaAvailableBanner} 337 * as {@code false}. 338 */ onUserDismissedCloudMediaAvailableBanner()339 void onUserDismissedCloudMediaAvailableBanner() { 340 if (!mShowCloudMediaAvailableBanner) { 341 Log.d(TAG, "Cloud media available banner visibility for current user is false on " 342 + "dismiss"); 343 } else { 344 mShowCloudMediaAvailableBanner = false; 345 } 346 } 347 348 /** 349 * Dismiss (hide) the 'Account Updated' banner 350 * 351 * Set the 'Account Updated' banner visibility {@link #mShowAccountUpdatedBanner} as 352 * {@code false}. 353 */ onUserDismissedAccountUpdatedBanner()354 void onUserDismissedAccountUpdatedBanner() { 355 if (!mShowAccountUpdatedBanner) { 356 Log.d(TAG, "Account Updated banner visibility for current user is false on dismiss"); 357 } else { 358 mShowAccountUpdatedBanner = false; 359 } 360 } 361 362 /** 363 * Dismiss (hide) the 'Choose Account' banner 364 * 365 * Set the 'Choose Account' banner visibility {@link #mShowChooseAccountBanner} as 366 * {@code false}. 367 */ onUserDismissedChooseAccountBanner()368 void onUserDismissedChooseAccountBanner() { 369 if (!mShowChooseAccountBanner) { 370 Log.d(TAG, "Choose Account banner visibility for current user is false on dismiss"); 371 } else { 372 mShowChooseAccountBanner = false; 373 } 374 } 375 loadCloudProviderInfo()376 private void loadCloudProviderInfo() { 377 FileInputStream fis = null; 378 final Map<String, String> lastCloudProviderDataMap = new HashMap<>(); 379 try { 380 if (!mLastCloudProviderDataFile.exists()) { 381 return; 382 } 383 384 final AtomicFile atomicLastCloudProviderDataFile = new AtomicFile( 385 mLastCloudProviderDataFile); 386 fis = atomicLastCloudProviderDataFile.openRead(); 387 lastCloudProviderDataMap.putAll(XmlUtils.readMapXml(fis)); 388 } catch (Exception e) { 389 Log.w(TAG, "Could not load the cloud provider info.", e); 390 } finally { 391 if (fis != null) { 392 try { 393 fis.close(); 394 } catch (Exception e) { 395 Log.w(TAG, "Failed to close the FileInputStream.", e); 396 } 397 } 398 mCloudProviderDataMap.clear(); 399 mCloudProviderDataMap.putAll(lastCloudProviderDataMap); 400 } 401 } 402 persistCloudProviderInfo(@ullable String cmpAuthority, @Nullable String cmpAccountName)403 private void persistCloudProviderInfo(@Nullable String cmpAuthority, 404 @Nullable String cmpAccountName) { 405 updateCloudProviderDataMap(cmpAuthority, cmpAccountName); 406 updateCloudProviderDataFile(); 407 } 408 updateCloudProviderDataMap(@ullable String cmpAuthority, @Nullable String cmpAccountName)409 private void updateCloudProviderDataMap(@Nullable String cmpAuthority, 410 @Nullable String cmpAccountName) { 411 mCloudProviderDataMap.clear(); 412 if (cmpAuthority != null) { 413 mCloudProviderDataMap.put(AUTHORITY, cmpAuthority); 414 } 415 if (cmpAccountName != null) { 416 mCloudProviderDataMap.put(ACCOUNT_NAME, cmpAccountName); 417 } 418 } 419 420 @VisibleForTesting updateCloudProviderDataFile()421 void updateCloudProviderDataFile() { 422 FileOutputStream fos = null; 423 final AtomicFile atomicLastCloudProviderDataFile = new AtomicFile( 424 mLastCloudProviderDataFile); 425 426 try { 427 fos = atomicLastCloudProviderDataFile.startWrite(); 428 XmlUtils.writeMapXml(mCloudProviderDataMap, fos); 429 atomicLastCloudProviderDataFile.finishWrite(fos); 430 } catch (Exception e) { 431 atomicLastCloudProviderDataFile.failWrite(fos); 432 Log.w(TAG, "Could not persist the cloud provider info.", e); 433 } 434 } 435 } 436