• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.content.Intent.ACTION_GET_CONTENT;
20 import static android.provider.MediaStore.ACTION_PICK_IMAGES;
21 import static android.provider.MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP;
22 import static android.provider.MediaStore.grantMediaReadForPackage;
23 
24 import static com.android.providers.media.MediaApplication.getConfigStore;
25 import static com.android.providers.media.photopicker.PhotoPickerSettingsActivity.EXTRA_CURRENT_USER_ID;
26 import static com.android.providers.media.photopicker.data.PickerResult.getPickerResponseIntent;
27 import static com.android.providers.media.photopicker.data.PickerResult.getPickerUrisForItems;
28 import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_PHOTOS_TAB;
29 
30 import android.annotation.UserIdInt;
31 import android.app.Activity;
32 import android.content.BroadcastReceiver;
33 import android.content.ComponentName;
34 import android.content.Context;
35 import android.content.Intent;
36 import android.content.IntentFilter;
37 import android.content.pm.PackageManager;
38 import android.content.res.Configuration;
39 import android.content.res.TypedArray;
40 import android.graphics.Color;
41 import android.graphics.Outline;
42 import android.graphics.Rect;
43 import android.graphics.drawable.ColorDrawable;
44 import android.graphics.drawable.Drawable;
45 import android.net.Uri;
46 import android.os.Binder;
47 import android.os.Build;
48 import android.os.Bundle;
49 import android.os.UserHandle;
50 import android.util.Log;
51 import android.util.TypedValue;
52 import android.view.Menu;
53 import android.view.MenuItem;
54 import android.view.MotionEvent;
55 import android.view.View;
56 import android.view.ViewOutlineProvider;
57 import android.view.WindowInsetsController;
58 import android.view.WindowManager;
59 import android.view.accessibility.AccessibilityManager;
60 import android.widget.TextView;
61 
62 import androidx.annotation.ColorInt;
63 import androidx.annotation.NonNull;
64 import androidx.annotation.Nullable;
65 import androidx.annotation.VisibleForTesting;
66 import androidx.appcompat.app.AppCompatActivity;
67 import androidx.appcompat.widget.Toolbar;
68 import androidx.fragment.app.FragmentManager;
69 import androidx.lifecycle.ViewModel;
70 import androidx.lifecycle.ViewModelProvider;
71 
72 import com.android.providers.media.ConfigStore;
73 import com.android.providers.media.R;
74 import com.android.providers.media.photopicker.data.PickerResult;
75 import com.android.providers.media.photopicker.data.Selection;
76 import com.android.providers.media.photopicker.data.UserIdManager;
77 import com.android.providers.media.photopicker.data.model.UserId;
78 import com.android.providers.media.photopicker.ui.TabContainerFragment;
79 import com.android.providers.media.photopicker.util.LayoutModeUtils;
80 import com.android.providers.media.photopicker.util.MimeFilterUtils;
81 import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
82 import com.android.providers.media.util.ForegroundThread;
83 
84 import com.google.android.material.bottomsheet.BottomSheetBehavior;
85 import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback;
86 import com.google.android.material.tabs.TabLayout;
87 import com.google.common.collect.Lists;
88 
89 import java.util.List;
90 
91 /**
92  * Photo Picker allows users to choose one or more photos and/or videos to share with an app. The
93  * app does not get access to all photos/videos.
94  */
95 public class PhotoPickerActivity extends AppCompatActivity {
96     private static final String TAG =  "PhotoPickerActivity";
97     private static final float BOTTOM_SHEET_PEEK_HEIGHT_PERCENTAGE = 0.60f;
98     private static final float HIDE_PROFILE_BUTTON_THRESHOLD = -0.5f;
99     private static final String LOGGER_INSTANCE_ID_ARG = "loggerInstanceIdArg";
100     private static final String EXTRA_PRELOAD_SELECTED =
101             "com.android.providers.media.photopicker.extra.PRELOAD_SELECTED";
102 
103     private ViewModelProvider mViewModelProvider;
104     private PickerViewModel mPickerViewModel;
105     private PreloaderInstanceHolder mPreloaderInstanceHolder;
106 
107     private Selection mSelection;
108     private BottomSheetBehavior mBottomSheetBehavior;
109     private View mBottomBar;
110     private View mBottomSheetView;
111     private View mFragmentContainerView;
112     private View mDragBar;
113     private View mProfileButton;
114     private TextView mPrivacyText;
115     private TabLayout mTabLayout;
116     private Toolbar mToolbar;
117     private CrossProfileListeners mCrossProfileListeners;
118 
119     @ColorInt
120     private int mDefaultBackgroundColor;
121 
122     @ColorInt
123     private int mToolBarIconColor;
124 
125     private int mToolbarHeight = 0;
126     private boolean mIsAccessibilityEnabled;
127     private boolean mShouldLogCancelledResult = true;
128 
129     @Override
onCreate(Bundle savedInstanceState)130     public void onCreate(Bundle savedInstanceState) {
131         // This is required as GET_CONTENT with type "*/*" is also received by PhotoPicker due
132         // to higher priority than DocumentsUi. "*/*" mime type filter is caught as it is a superset
133         // of "image/*" and "video/*".
134         if (rerouteGetContentRequestIfRequired()) {
135             // This activity is finishing now: we should not run the setup below,
136             // BUT before we return we have to call super.onCreate() (otherwise we are we will get
137             // SuperNotCalledException: Activity did not call through to super.onCreate())
138             super.onCreate(savedInstanceState);
139             return;
140         }
141 
142         // We use the device default theme as the base theme. Apply the material them for the
143         // material components. We use force "false" here, only values that are not already defined
144         // in the base theme will be copied.
145         getTheme().applyStyle(R.style.PickerMaterialTheme, /* force */ false);
146 
147         super.onCreate(savedInstanceState);
148 
149         setContentView(R.layout.activity_photo_picker);
150 
151         mToolbar = findViewById(R.id.toolbar);
152         setSupportActionBar(mToolbar);
153         getSupportActionBar().setDisplayHomeAsUpEnabled(true);
154 
155         final int[] attrs = new int[]{R.attr.actionBarSize, R.attr.pickerTextColor};
156         final TypedArray ta = obtainStyledAttributes(attrs);
157         // Save toolbar height so that we can use it as padding for FragmentContainerView
158         mToolbarHeight = ta.getDimensionPixelSize(/* index */ 0, /* defValue */ -1);
159         mToolBarIconColor = ta.getColor(/* index */ 1,/* defValue */ -1);
160         ta.recycle();
161 
162         mDefaultBackgroundColor = getColor(R.color.picker_background_color);
163 
164         mViewModelProvider = new ViewModelProvider(this);
165         mPickerViewModel = getOrCreateViewModel();
166 
167         final Intent intent = getIntent();
168         try {
169             mPickerViewModel.parseValuesFromIntent(intent);
170         } catch (IllegalArgumentException e) {
171             Log.e(TAG, "Finish activity due to an exception while parsing extras", e);
172             finishWithoutLoggingCancelledResult();
173             return;
174         }
175         mSelection = mPickerViewModel.getSelection();
176 
177         mDragBar = findViewById(R.id.drag_bar);
178         mPrivacyText = findViewById(R.id.privacy_text);
179         mBottomBar = findViewById(R.id.picker_bottom_bar);
180         mProfileButton = findViewById(R.id.profile_button);
181 
182         mTabLayout = findViewById(R.id.tab_layout);
183 
184         final AccessibilityManager am = getSystemService(AccessibilityManager.class);
185         mIsAccessibilityEnabled = am.isEnabled();
186         am.addAccessibilityStateChangeListener(enabled -> mIsAccessibilityEnabled = enabled);
187 
188         initBottomSheetBehavior();
189         restoreState(savedInstanceState);
190 
191         final String intentAction = intent != null ? intent.getAction() : null;
192         // Call this after state is restored, to use the correct LOGGER_INSTANCE_ID_ARG
193         mPickerViewModel.logPickerOpened(Binder.getCallingUid(), getCallingPackage(), intentAction);
194 
195         // Save the fragment container layout so that we can adjust the padding based on preview or
196         // non-preview mode.
197         mFragmentContainerView = findViewById(R.id.fragment_container);
198 
199         mCrossProfileListeners = new CrossProfileListeners();
200 
201         mPreloaderInstanceHolder = mViewModelProvider.get(PreloaderInstanceHolder.class);
202         if (mPreloaderInstanceHolder.preloader != null) {
203             subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader);
204         }
205     }
206 
207     @Override
onDestroy()208     public void onDestroy() {
209         super.onDestroy();
210         if (mCrossProfileListeners != null) {
211             // This is required to unregister any broadcast receivers.
212             mCrossProfileListeners.onDestroy();
213         }
214     }
215 
216     /**
217      * Warning: This method is needed for tests, we are not customizing anything here.
218      * Allowing ourselves to control ViewModel creation helps us mock the ViewModel for test.
219      */
220     @VisibleForTesting
221     @NonNull
getOrCreateViewModel()222     protected PickerViewModel getOrCreateViewModel() {
223         return mViewModelProvider.get(PickerViewModel.class);
224     }
225 
226     @Override
dispatchTouchEvent(MotionEvent event)227     public boolean dispatchTouchEvent(MotionEvent event){
228         if (event.getAction() == MotionEvent.ACTION_DOWN) {
229             if (mBottomSheetBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) {
230 
231                 Rect outRect = new Rect();
232                 mBottomSheetView.getGlobalVisibleRect(outRect);
233 
234                 if (!outRect.contains((int)event.getRawX(), (int)event.getRawY())) {
235                     mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
236                 }
237             }
238         }
239         return super.dispatchTouchEvent(event);
240     }
241 
242     @Override
onSupportNavigateUp()243     public boolean onSupportNavigateUp() {
244         onBackPressed();
245         return true;
246     }
247 
248     @Override
setTitle(CharSequence title)249     public void setTitle(CharSequence title) {
250         super.setTitle(title);
251         getSupportActionBar().setTitle(title);
252     }
253 
254     /**
255      * Called when owning activity is saving state to be used to restore state during creation.
256      *
257      * @param state Bundle to save state
258      */
259     @Override
onSaveInstanceState(Bundle state)260     public void onSaveInstanceState(Bundle state) {
261         super.onSaveInstanceState(state);
262         saveBottomSheetState();
263         state.putParcelable(LOGGER_INSTANCE_ID_ARG, mPickerViewModel.getInstanceId());
264     }
265 
266     @Override
onCreateOptionsMenu(@onNull Menu menu)267     public boolean onCreateOptionsMenu(@NonNull Menu menu) {
268         getMenuInflater().inflate(R.menu.picker_overflow_menu, menu);
269         return true;
270     }
271 
272     @Override
onPrepareOptionsMenu(@onNull Menu menu)273     public boolean onPrepareOptionsMenu(@NonNull Menu menu) {
274         super.onPrepareOptionsMenu(menu);
275         // All logic to hide/show an item in the menu must be in this method
276         final MenuItem settingsMenuItem = menu.findItem(R.id.settings);
277 
278         // TODO(b/195009187): Settings menu item is hidden by default till Settings page is
279         // completely developed.
280         settingsMenuItem.setVisible(shouldShowSettingsScreen());
281 
282         // Browse menu item allows users to launch DocumentsUI. This item should only be shown if
283         // PhotoPicker was opened via {@link #ACTION_GET_CONTENT}.
284         menu.findItem(R.id.browse).setVisible(isGetContentAction());
285 
286         return menu.hasVisibleItems();
287     }
288 
289     @Override
onOptionsItemSelected(MenuItem item)290     public boolean onOptionsItemSelected(MenuItem item) {
291         switch (item.getItemId()) {
292             case R.id.browse:
293                 mPickerViewModel.logBrowseToDocumentsUi(Binder.getCallingUid(),
294                         getCallingPackage());
295                 launchDocumentsUiAndFinishPicker();
296                 return true;
297             case R.id.settings:
298                 startSettingsActivity();
299                 return true;
300             default:
301                 // Continue to return the result of base class' onOptionsItemSelected(item)
302         }
303         return super.onOptionsItemSelected(item);
304     }
305 
306     /**
307      * Launch the Photo Picker settings page where user can view/edit current cloud media provider.
308      */
startSettingsActivity()309     public void startSettingsActivity() {
310         final Intent intent = new Intent(this, PhotoPickerSettingsActivity.class);
311         intent.putExtra(EXTRA_CURRENT_USER_ID, getCurrentUserId());
312         startActivity(intent);
313     }
314 
315     @Override
onRestart()316     public void onRestart() {
317         super.onRestart();
318 
319         // TODO(b/262001857): For each profile, conditionally reset PhotoPicker when cloud provider
320         //  app or account has changed. Currently, we'll reset picker each time it restarts when
321         //  settings page is enabled to avoid the scenario where cloud provider app or account has
322         //  changed but picker continues to show stale data from old provider app and account.
323         if (shouldShowSettingsScreen()) {
324             reset(/* switchToPersonalProfile */ false);
325         }
326     }
327 
328     /**
329      * @return {@code true} if the intent was re-routed to the DocumentsUI (and this
330      *  {@code PhotoPickerActivity} is {@link #isFinishing()} now). {@code false} - otherwise.
331      */
rerouteGetContentRequestIfRequired()332     private boolean rerouteGetContentRequestIfRequired() {
333         final Intent intent = getIntent();
334         if (!ACTION_GET_CONTENT.equals(intent.getAction())) {
335             return false;
336         }
337 
338         // TODO(b/232775643): Workaround to support PhotoPicker invoked from DocumentsUi.
339         // GET_CONTENT for all (media and non-media) files opens DocumentsUi, but it still shows
340         // "Photo Picker app option. When the user clicks on "Photo Picker", the same intent which
341         // includes filters to show non-media files as well is forwarded to PhotoPicker.
342         // Make sure Photo Picker is opened when the intent is explicitly forwarded by documentsUi
343         if (isIntentReferredByDocumentsUi(getReferrer())) {
344             Log.i(TAG, "Open PhotoPicker when a forwarded ACTION_GET_CONTENT intent is received");
345             return false;
346         }
347 
348         // Check if we can handle the specified MIME types.
349         // If we can - do not reroute and thus return false.
350         if (!MimeFilterUtils.requiresUnsupportedFilters(intent)) return false;
351 
352         launchDocumentsUiAndFinishPicker();
353         return true;
354     }
355 
isIntentReferredByDocumentsUi(Uri referrerAppUri)356     private boolean isIntentReferredByDocumentsUi(Uri referrerAppUri) {
357         ComponentName documentsUiComponentName = getDocumentsUiComponentName(this);
358         String documentsUiPackageName = documentsUiComponentName != null
359                 ? documentsUiComponentName.getPackageName() : null;
360         return referrerAppUri != null && referrerAppUri.getHost().equals(documentsUiPackageName);
361     }
362 
launchDocumentsUiAndFinishPicker()363     private void launchDocumentsUiAndFinishPicker() {
364         Log.i(TAG, "Launch DocumentsUI and finish picker");
365 
366         startActivityAsUser(getDocumentsUiForwardingIntent(this, getIntent()),
367                 UserId.CURRENT_USER.getUserHandle());
368         // RESULT_CANCELLED is not returned to the calling app as the DocumentsUi result will be
369         // returned. We don't have to log as this flow can be called in 2 cases:
370         // 1. GET_CONTENT had non-media filters, so the user or the app should be unaffected as they
371         // see that DocumentsUi was opened directly.
372         // 2. User clicked on "Browse.." button, in that case we already log that event separately.
373         finishWithoutLoggingCancelledResult();
374     }
375 
376     @VisibleForTesting
getDocumentsUiForwardingIntent(Context context, Intent intent)377     static Intent getDocumentsUiForwardingIntent(Context context, Intent intent) {
378         intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
379         intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
380         intent.setComponent(getDocumentsUiComponentName(context));
381         return intent;
382     }
383 
getDocumentsUiComponentName(Context context)384     private static ComponentName getDocumentsUiComponentName(Context context) {
385         final PackageManager pm = context.getPackageManager();
386         // DocumentsUI is the default handler for ACTION_OPEN_DOCUMENT
387         final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
388         intent.addCategory(Intent.CATEGORY_OPENABLE);
389         intent.setType("*/*");
390         return intent.resolveActivity(pm);
391     }
392 
restoreState(Bundle savedInstanceState)393     private void restoreState(Bundle savedInstanceState) {
394         if (savedInstanceState != null) {
395             restoreBottomSheetState();
396             mPickerViewModel.setInstanceId(
397                     savedInstanceState.getParcelable(LOGGER_INSTANCE_ID_ARG));
398         } else {
399             setupInitialLaunchState();
400         }
401     }
402 
403     /**
404      * Sets up states for the initial launch. This includes updating common layouts, selecting
405      * Photos tab item and saving the current bottom sheet state for later.
406      */
setupInitialLaunchState()407     private void setupInitialLaunchState() {
408         updateCommonLayouts(MODE_PHOTOS_TAB, /* title */ "");
409         TabContainerFragment.show(getSupportFragmentManager());
410         saveBottomSheetState();
411     }
412 
initBottomSheetBehavior()413     private void initBottomSheetBehavior() {
414         mBottomSheetView = findViewById(R.id.bottom_sheet);
415         mBottomSheetBehavior = BottomSheetBehavior.from(mBottomSheetView);
416         initStateForBottomSheet();
417 
418         mBottomSheetBehavior.addBottomSheetCallback(createBottomSheetCallBack());
419         setRoundedCornersForBottomSheet();
420     }
421 
createBottomSheetCallBack()422     private BottomSheetCallback createBottomSheetCallBack() {
423         return new BottomSheetCallback() {
424             private boolean mIsHiddenDueToBottomSheetClosing = false;
425             @Override
426             public void onStateChanged(@NonNull View bottomSheet, int newState) {
427                 if (newState == BottomSheetBehavior.STATE_HIDDEN) {
428                     finish();
429                 }
430                 saveBottomSheetState();
431             }
432 
433             @Override
434             public void onSlide(@NonNull View bottomSheet, float slideOffset) {
435                 // slideOffset = -1 is when bottomsheet is completely hidden
436                 // slideOffset = 0 is when bottomsheet is in collapsed mode
437                 // slideOffset = 1 is when bottomsheet is in expanded mode
438                 // We hide the Profile button if the bottomsheet is 50% in between collapsed state
439                 // and hidden state.
440                 if (slideOffset < HIDE_PROFILE_BUTTON_THRESHOLD &&
441                         mProfileButton.getVisibility() == View.VISIBLE) {
442                     mProfileButton.setVisibility(View.GONE);
443                     mIsHiddenDueToBottomSheetClosing = true;
444                     return;
445                 }
446 
447                 // We need to handle this state if the user is swiping till the bottom of the
448                 // screen but then swipes up bottom sheet suddenly
449                 if (slideOffset > HIDE_PROFILE_BUTTON_THRESHOLD &&
450                         mIsHiddenDueToBottomSheetClosing) {
451                     mProfileButton.setVisibility(View.VISIBLE);
452                     mIsHiddenDueToBottomSheetClosing = false;
453                 }
454             }
455         };
456     }
457 
setRoundedCornersForBottomSheet()458     private void setRoundedCornersForBottomSheet() {
459         final float cornerRadius =
460                 getResources().getDimensionPixelSize(R.dimen.picker_top_corner_radius);
461         final ViewOutlineProvider viewOutlineProvider = new ViewOutlineProvider() {
462             @Override
463             public void getOutline(final View view, final Outline outline) {
464                 outline.setRoundRect(0, 0, view.getWidth(),
465                         (int)(view.getHeight() + cornerRadius), cornerRadius);
466             }
467         };
468         mBottomSheetView.setOutlineProvider(viewOutlineProvider);
469     }
470 
initStateForBottomSheet()471     private void initStateForBottomSheet() {
472         if (!mIsAccessibilityEnabled && !mSelection.canSelectMultiple()
473                 && !isOrientationLandscape()) {
474             final int peekHeight = getBottomSheetPeekHeight(this);
475             mBottomSheetBehavior.setPeekHeight(peekHeight);
476             mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
477         } else {
478             mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
479             mBottomSheetBehavior.setSkipCollapsed(true);
480         }
481     }
482 
getBottomSheetPeekHeight(Context context)483     private static int getBottomSheetPeekHeight(Context context) {
484         final WindowManager windowManager = context.getSystemService(WindowManager.class);
485         final Rect displayBounds = windowManager.getCurrentWindowMetrics().getBounds();
486         return (int) (displayBounds.height() * BOTTOM_SHEET_PEEK_HEIGHT_PERCENTAGE);
487     }
488 
restoreBottomSheetState()489     private void restoreBottomSheetState() {
490         // BottomSheet is always EXPANDED for landscape
491         if (isOrientationLandscape()) {
492             return;
493         }
494         final int savedState = mPickerViewModel.getBottomSheetState();
495         if (isValidBottomSheetState(savedState)) {
496             mBottomSheetBehavior.setState(savedState);
497         }
498     }
499 
saveBottomSheetState()500     private void saveBottomSheetState() {
501         // Do not save state for landscape or preview mode. This is because they are always in
502         // STATE_EXPANDED state.
503         if (isOrientationLandscape() || !mBottomSheetView.getClipToOutline()) {
504             return;
505         }
506         mPickerViewModel.setBottomSheetState(mBottomSheetBehavior.getState());
507     }
508 
isValidBottomSheetState(int state)509     private boolean isValidBottomSheetState(int state) {
510         return state == BottomSheetBehavior.STATE_COLLAPSED ||
511                 state == BottomSheetBehavior.STATE_EXPANDED;
512     }
513 
isOrientationLandscape()514     private boolean isOrientationLandscape() {
515         return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
516     }
517 
setResultAndFinishSelf()518     public void setResultAndFinishSelf() {
519         logPickerSelectionConfirmed(mSelection.getSelectedItems().size());
520 
521         if (shouldPreloadSelectedItems()) {
522             final var uris = PickerResult.getPickerUrisForItems(mSelection.getSelectedItems());
523             mPreloaderInstanceHolder.preloader =
524                     SelectedMediaPreloader.preload(/* activity */ this, uris);
525             subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader);
526         } else {
527             setResultAndFinishSelfInternal();
528         }
529     }
530 
setResultAndFinishSelfInternal()531     private void setResultAndFinishSelfInternal() {
532         // In addition to the activity result, add the selected files to the MediaProvider
533         // media_grants database.
534         if (isUserSelectImagesForAppAction()) {
535             setResultForUserSelectImagesForAppAction();
536         } else {
537             setResultForPickImagesOrGetContentAction();
538         }
539 
540         finishWithoutLoggingCancelledResult();
541     }
542 
setResultForUserSelectImagesForAppAction()543     private void setResultForUserSelectImagesForAppAction() {
544         // Since Photopicker is in permission mode, don't send back URI grants.
545         setResult(RESULT_OK);
546         // The permission controller will pass the requesting package's UID here
547         final Bundle extras = getIntent().getExtras();
548         final int uid = extras.getInt(Intent.EXTRA_UID);
549         final List<Uri> uris = getPickerUrisForItems(mSelection.getSelectedItems());
550         ForegroundThread.getExecutor().execute(() -> {
551             // Handle grants in another thread to not block the UI.
552             grantMediaReadForPackage(getApplicationContext(), uid, uris);
553         });
554     }
555 
setResultForPickImagesOrGetContentAction()556     private void setResultForPickImagesOrGetContentAction() {
557         final Intent resultData = getPickerResponseIntent(
558                 mSelection.canSelectMultiple(),
559                 mSelection.getSelectedItems());
560         setResult(RESULT_OK, resultData);
561     }
562 
shouldPreloadSelectedItems()563     private boolean shouldPreloadSelectedItems() {
564         // Only preload if the cloud media may be shown in the PhotoPicker.
565         if (!isCloudMediaAvailable()) {
566             return false;
567         }
568 
569         final boolean isGetContent = isGetContentAction();
570         final boolean isPickImages = isPickImagesAction();
571         final ConfigStore cs = getConfigStore();
572 
573         if (getIntent().hasExtra(EXTRA_PRELOAD_SELECTED)) {
574             if (Build.isDebuggable()
575                     || (isPickImages && cs.shouldPickerRespectPreloadArgumentForPickImages())) {
576                 return getIntent().getBooleanExtra(EXTRA_PRELOAD_SELECTED,
577                         /* default, not used */ false);
578             }
579         }
580 
581         if (isGetContent) {
582             return cs.shouldPickerPreloadForGetContent();
583         } else if (isPickImages) {
584             return cs.shouldPickerPreloadForPickImages();
585         } else {
586             Log.w(TAG, "Not preloading selection for \"" + getIntent().getAction() + "\" action");
587             return false;
588         }
589     }
590 
subscribeToSelectedMediaPreloader(@onNull SelectedMediaPreloader preloader)591     private void subscribeToSelectedMediaPreloader(@NonNull SelectedMediaPreloader preloader) {
592         preloader.getIsFinishedLiveData().observe(
593                 /* lifecycleOwner */ PhotoPickerActivity.this,
594                 isFinished -> {
595                     if (isFinished) {
596                         setResultAndFinishSelfInternal();
597                     }
598                 });
599     }
600 
601     /**
602      * NOTE: this may wrongly return {@code false} if called before {@link PickerViewModel} had a
603      * chance to fetch the authority and the account of the current
604      * {@link android.provider.CloudMediaProvider}.
605      * However, this may only happen very early on in the lifecycle.
606      */
isCloudMediaAvailable()607     private boolean isCloudMediaAvailable() {
608         return mPickerViewModel.getCloudMediaProviderAuthorityLiveData().getValue() != null
609                 && mPickerViewModel.getCloudMediaAccountNameLiveData().getValue() != null;
610     }
611 
612     /**
613      * This should be called if:
614      * * We are finishing Picker explicitly before the user has seen PhotoPicker UI due to known
615      *   checks/workflow.
616      * * We are not returning {@link Activity#RESULT_CANCELED}
617      */
finishWithoutLoggingCancelledResult()618     private void finishWithoutLoggingCancelledResult() {
619         mShouldLogCancelledResult = false;
620         finish();
621     }
622 
623     @Override
finish()624     public void finish() {
625         if (mShouldLogCancelledResult) {
626             logPickerCancelled();
627         }
628         super.finish();
629     }
630 
logPickerSelectionConfirmed(int countOfItemsConfirmed)631     private void logPickerSelectionConfirmed(int countOfItemsConfirmed) {
632         mPickerViewModel.logPickerConfirm(Binder.getCallingUid(), getCallingPackage(),
633                 countOfItemsConfirmed);
634     }
635 
logPickerCancelled()636     private void logPickerCancelled() {
637         mPickerViewModel.logPickerCancel(Binder.getCallingUid(), getCallingPackage());
638     }
639 
640     @UserIdInt
getCurrentUserId()641     private int getCurrentUserId() {
642         final UserIdManager userIdManager = mPickerViewModel.getUserIdManager();
643         return userIdManager.getCurrentUserProfileId().getIdentifier();
644     }
645 
646     /**
647      * Updates the common views such as Title, Toolbar, Navigation bar, status bar and bottom sheet
648      * behavior
649      *
650      * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update.
651      * @param title the title to set for the Activity
652      */
updateCommonLayouts(LayoutModeUtils.Mode mode, String title)653     public void updateCommonLayouts(LayoutModeUtils.Mode mode, String title) {
654         updateTitle(title);
655         updateToolbar(mode);
656         updateStatusBarAndNavigationBar(mode);
657         updateBottomSheetBehavior(mode);
658         updateFragmentContainerViewPadding(mode);
659         updateDragBarVisibility(mode);
660         updateHeaderTextVisibility(mode);
661         // The bottom bar and profile button are not shown on preview, hide them in preview. We
662         // handle the visibility of them in TabFragment. We don't need to make them shown in
663         // non-preview page here.
664         if (mode.isPreview) {
665             mBottomBar.setVisibility(View.GONE);
666             mProfileButton.setVisibility(View.GONE);
667         }
668     }
669 
updateTitle(String title)670     private void updateTitle(String title) {
671         setTitle(title);
672     }
673 
674     /**
675      * Updates the icons and show/hide the tab layout with {@code mode}.
676      *
677      * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update.
678      */
updateToolbar(@onNull LayoutModeUtils.Mode mode)679     private void updateToolbar(@NonNull LayoutModeUtils.Mode mode) {
680         final boolean isPreview = mode.isPreview;
681         final boolean shouldShowTabLayout = mode.isPhotosTabOrAlbumsTab;
682         // 1. Set the tabLayout visibility
683         mTabLayout.setVisibility(shouldShowTabLayout ? View.VISIBLE : View.GONE);
684 
685         // 2. Set the toolbar color
686         final ColorDrawable toolbarColor;
687         if (isPreview && !shouldShowTabLayout) {
688             if (isOrientationLandscape()) {
689                 // Toolbar in Preview will have transparent color in Landscape mode.
690                 toolbarColor = new ColorDrawable(getColor(android.R.color.transparent));
691             } else {
692                 // Toolbar in Preview will have a solid color with 90% opacity in Portrait mode.
693                 toolbarColor = new ColorDrawable(getColor(R.color.preview_scrim_solid_color));
694             }
695         } else {
696             toolbarColor = new ColorDrawable(mDefaultBackgroundColor);
697         }
698         getSupportActionBar().setBackgroundDrawable(toolbarColor);
699 
700         // 3. Set the toolbar icon.
701         final Drawable icon;
702         if (shouldShowTabLayout) {
703             icon = getDrawable(R.drawable.ic_close);
704         } else {
705             icon = getDrawable(R.drawable.ic_arrow_back);
706             // Preview mode has dark background, hence icons will be WHITE in color
707             icon.setTint(isPreview ? Color.WHITE : mToolBarIconColor);
708         }
709         getSupportActionBar().setHomeAsUpIndicator(icon);
710         getSupportActionBar().setHomeActionContentDescription(
711                 shouldShowTabLayout ? android.R.string.cancel
712                         : R.string.abc_action_bar_up_description);
713         if (mToolbar.getOverflowIcon() != null) {
714             mToolbar.getOverflowIcon().setTint(isPreview ? Color.WHITE : mToolBarIconColor);
715         }
716     }
717 
718     /**
719      * Updates status bar and navigation bar
720      *
721      * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update.
722      */
updateStatusBarAndNavigationBar(@onNull LayoutModeUtils.Mode mode)723     private void updateStatusBarAndNavigationBar(@NonNull LayoutModeUtils.Mode mode) {
724         final boolean isPreview = mode.isPreview;
725         final int navigationBarColor = isPreview ? getColor(R.color.preview_background_color) :
726                 mDefaultBackgroundColor;
727         getWindow().setNavigationBarColor(navigationBarColor);
728 
729         final int statusBarColor = isPreview ? getColor(R.color.preview_background_color) :
730                 getColor(android.R.color.transparent);
731         getWindow().setStatusBarColor(statusBarColor);
732 
733         // Update the system bar appearance
734         final int mask = WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
735         int appearance = 0;
736         if (!isPreview) {
737             final int uiModeNight =
738                     getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
739 
740             if (uiModeNight == Configuration.UI_MODE_NIGHT_NO) {
741                 // If the system is not in Dark theme, set the system bars to light mode.
742                 appearance = mask;
743             }
744         }
745         getWindow().getInsetsController().setSystemBarsAppearance(appearance, mask);
746     }
747 
748     /**
749      * Updates the bottom sheet behavior
750      *
751      * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update.
752      */
updateBottomSheetBehavior(@onNull LayoutModeUtils.Mode mode)753     private void updateBottomSheetBehavior(@NonNull LayoutModeUtils.Mode mode) {
754         final boolean isPreview = mode.isPreview;
755         if (mBottomSheetView != null) {
756             mBottomSheetView.setClipToOutline(!isPreview);
757             // TODO(b/197241815): Add animation downward swipe for preview should go back to
758             // the photo in photos grid
759             mBottomSheetBehavior.setDraggable(!isPreview);
760         }
761         if (isPreview) {
762             if (mBottomSheetBehavior.getState() != BottomSheetBehavior.STATE_EXPANDED) {
763                 // Sets bottom sheet behavior state to STATE_EXPANDED if it's not already expanded.
764                 // This is useful when user goes to Preview mode which is always Full screen.
765                 // TODO(b/197241815): Add animation preview to full screen and back transition to
766                 // partial screen. This is similar to long press animation.
767                 mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
768             }
769         } else {
770             restoreBottomSheetState();
771         }
772     }
773 
774     /**
775      * Updates the FragmentContainerView padding.
776      * <p>
777      * For Preview mode, toolbar overlaps the Fragment content, hence the padding will be set to 0.
778      * For Non-Preview mode, toolbar doesn't overlap the contents of the fragment, hence we set the
779      * padding as the height of the toolbar.
780      */
updateFragmentContainerViewPadding(@onNull LayoutModeUtils.Mode mode)781     private void updateFragmentContainerViewPadding(@NonNull LayoutModeUtils.Mode mode) {
782         if (mFragmentContainerView == null) return;
783 
784         final int topPadding;
785         if (mode.isPreview) {
786             topPadding = 0;
787         } else {
788             topPadding = mToolbarHeight;
789         }
790 
791         mFragmentContainerView.setPadding(mFragmentContainerView.getPaddingLeft(),
792                 topPadding, mFragmentContainerView.getPaddingRight(),
793                 mFragmentContainerView.getPaddingBottom());
794     }
795 
updateDragBarVisibility(@onNull LayoutModeUtils.Mode mode)796     private void updateDragBarVisibility(@NonNull LayoutModeUtils.Mode mode) {
797         final boolean shouldShowDragBar = !mode.isPreview;
798         mDragBar.setVisibility(shouldShowDragBar ? View.VISIBLE : View.GONE);
799     }
800 
updateHeaderTextVisibility(@onNull LayoutModeUtils.Mode mode)801     private void updateHeaderTextVisibility(@NonNull LayoutModeUtils.Mode mode) {
802         // The privacy text is only shown on the Photos tab and Albums tab when not in
803         // permission select mode.
804         final boolean shouldShowPrivacyMessage = mode.isPhotosTabOrAlbumsTab;
805 
806         if (!shouldShowPrivacyMessage) {
807             mPrivacyText.setVisibility(View.GONE);
808             return;
809         }
810 
811         if (mPickerViewModel.isUserSelectForApp()) {
812             mPrivacyText.setText(R.string.picker_header_permissions);
813             mPrivacyText.setTextSize(
814                     TypedValue.COMPLEX_UNIT_PX,
815                     getResources().getDimension(R.dimen.picker_user_select_header_text_size));
816         } else {
817             mPrivacyText.setText(R.string.picker_privacy_message);
818             mPrivacyText.setTextSize(
819                     TypedValue.COMPLEX_UNIT_PX,
820                     getResources().getDimension(R.dimen.picker_privacy_text_size));
821         }
822 
823         mPrivacyText.setVisibility(View.VISIBLE);
824     }
825 
826     /**
827      * Reset to Photo Picker initial launch state (Photos grid tab) in personal profile mode.
828      * @param switchToPersonalProfile is true then set personal profile as current profile.
829      */
reset(boolean switchToPersonalProfile)830     private void reset(boolean switchToPersonalProfile) {
831         mPickerViewModel.reset(switchToPersonalProfile);
832         setupInitialLaunchState();
833     }
834 
835     /**
836      * Returns {@code true} if settings page is enabled.
837      */
shouldShowSettingsScreen()838     private boolean shouldShowSettingsScreen() {
839         if (mPickerViewModel.shouldShowOnlyLocalFeatures()) {
840             return false;
841         }
842 
843         final ComponentName componentName = new ComponentName(this,
844                 PhotoPickerSettingsActivity.class);
845         return getPackageManager().getComponentEnabledSetting(componentName)
846                 == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
847     }
848 
849     /**
850      * Returns {@code true} if intent action is {@link ACTION_GET_CONTENT}.
851      */
isGetContentAction()852     private boolean isGetContentAction() {
853         return ACTION_GET_CONTENT.equals(getIntent().getAction());
854     }
855 
856     /**
857      * Returns {@code true} if intent action is {@link ACTION_PICK_IMAGES}.
858      */
isPickImagesAction()859     private boolean isPickImagesAction() {
860         return ACTION_PICK_IMAGES.equals(getIntent().getAction());
861     }
862 
863     /**
864      * Returns {@code true} if intent action is {@link ACTION_USER_SELECT_IMAGES_FOR_APP}
865      * (the 3-way storage permission grant flow)
866      */
isUserSelectImagesForAppAction()867     private boolean isUserSelectImagesForAppAction() {
868         return ACTION_USER_SELECT_IMAGES_FOR_APP.equals(getIntent().getAction());
869     }
870 
871     private class CrossProfileListeners {
872 
873         private final List<String> MANAGED_PROFILE_FILTER_ACTIONS = Lists.newArrayList(
874                 Intent.ACTION_MANAGED_PROFILE_ADDED, // add profile button switch
875                 Intent.ACTION_MANAGED_PROFILE_REMOVED, // remove profile button switch
876                 Intent.ACTION_MANAGED_PROFILE_UNLOCKED, // activate profile button switch
877                 Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE // disable profile button switch
878         );
879 
880         private final UserIdManager mUserIdManager;
881 
CrossProfileListeners()882         public CrossProfileListeners() {
883             mUserIdManager = mPickerViewModel.getUserIdManager();
884 
885             registerBroadcastReceivers();
886         }
887 
onDestroy()888         public void onDestroy() {
889             unregisterReceiver(mReceiver);
890         }
891 
892         private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
893             @Override
894             public void onReceive(Context context, Intent intent) {
895                 final String action = intent.getAction();
896 
897                 final UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER);
898                 final UserId userId = UserId.of(userHandle);
899 
900                 // We only need to refresh the layout when the received profile user is the
901                 // managed user corresponding to the current profile or a new work profile is added
902                 // for the current user.
903                 if (!userId.equals(mUserIdManager.getManagedUserId()) &&
904                         !action.equals(Intent.ACTION_MANAGED_PROFILE_ADDED)) {
905                     return;
906                 }
907 
908                 switch (action) {
909                     case Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE:
910                         handleWorkProfileOff();
911                         break;
912                     case Intent.ACTION_MANAGED_PROFILE_REMOVED:
913                         handleWorkProfileRemoved();
914                         break;
915                     case Intent.ACTION_MANAGED_PROFILE_UNLOCKED:
916                         handleWorkProfileOn();
917                         break;
918                     case Intent.ACTION_MANAGED_PROFILE_ADDED:
919                         handleWorkProfileAdded();
920                         break;
921                     default:
922                         // do nothing
923                 }
924             }
925         };
926 
registerBroadcastReceivers()927         private void registerBroadcastReceivers() {
928             final IntentFilter managedProfileFilter = new IntentFilter();
929             for (String managedProfileAction : MANAGED_PROFILE_FILTER_ACTIONS) {
930                 managedProfileFilter.addAction(managedProfileAction);
931             }
932             registerReceiver(mReceiver, managedProfileFilter);
933         }
934 
handleWorkProfileOff()935         private void handleWorkProfileOff() {
936             if (mUserIdManager.isManagedUserSelected()) {
937                 switchToPersonalProfileInitialLaunchState();
938             }
939             mUserIdManager.updateWorkProfileOffValue();
940         }
941 
handleWorkProfileRemoved()942         private void handleWorkProfileRemoved() {
943             if (mUserIdManager.isManagedUserSelected()) {
944                 switchToPersonalProfileInitialLaunchState();
945             }
946             mUserIdManager.resetUserIds();
947         }
948 
handleWorkProfileAdded()949         private void handleWorkProfileAdded() {
950             mUserIdManager.resetUserIds();
951         }
952 
handleWorkProfileOn()953         private void handleWorkProfileOn() {
954             // Update UI for switch to profile button
955             // When the managed profile becomes available, the provider may not be available
956             // immediately, we need to check if it is ready before we reload the content.
957             mUserIdManager.waitForMediaProviderToBeAvailable();
958         }
959 
switchToPersonalProfileInitialLaunchState()960         private void switchToPersonalProfileInitialLaunchState() {
961             final FragmentManager fragmentManager = getSupportFragmentManager();
962             // Clear all back stacks in FragmentManager
963             fragmentManager.popBackStackImmediate(/* name */ null,
964                     FragmentManager.POP_BACK_STACK_INCLUSIVE);
965 
966             // We reset the state of the PhotoPicker as we do not want to make any
967             // assumptions on the state of the PhotoPicker when it was in Work Profile mode.
968             reset(/* switchToPersonalProfile */ true);
969         }
970     }
971 
972     /**
973      * A {@link ViewModel} class only responsible for keeping track of "active"
974      * {@link SelectedMediaPreloader} instance (if any).
975      * This class has to be public, since somewhere in {@link ViewModelProvider} it will try to use
976      * reflection to create an instance of this class.
977      */
978     public static class PreloaderInstanceHolder extends ViewModel {
979         @Nullable
980         SelectedMediaPreloader preloader;
981     }
982 }
983