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