• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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