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