| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.providers.media.photopicker; |
| |
| import static android.content.Intent.ACTION_GET_CONTENT; |
| import static android.provider.MediaStore.ACTION_PICK_IMAGES; |
| import static android.provider.MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP; |
| import static android.provider.MediaStore.grantMediaReadForPackage; |
| |
| import static com.android.providers.media.photopicker.PhotoPickerSettingsActivity.EXTRA_CURRENT_USER_ID; |
| import static com.android.providers.media.photopicker.data.PickerResult.getPickerResponseIntent; |
| import static com.android.providers.media.photopicker.data.PickerResult.getPickerUrisForItems; |
| import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_PHOTOS_TAB; |
| |
| import android.annotation.UserIdInt; |
| import android.app.Activity; |
| import android.content.BroadcastReceiver; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.PackageManager; |
| import android.content.res.Configuration; |
| import android.content.res.TypedArray; |
| import android.graphics.Color; |
| import android.graphics.Outline; |
| import android.graphics.Rect; |
| import android.graphics.drawable.ColorDrawable; |
| import android.graphics.drawable.Drawable; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.UserHandle; |
| import android.provider.MediaStore; |
| import android.util.Log; |
| import android.util.TypedValue; |
| import android.view.Menu; |
| import android.view.MenuItem; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewOutlineProvider; |
| import android.view.WindowInsetsController; |
| import android.view.WindowManager; |
| import android.view.accessibility.AccessibilityManager; |
| import android.widget.TextView; |
| |
| import androidx.annotation.ColorInt; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.appcompat.app.AppCompatActivity; |
| import androidx.appcompat.widget.Toolbar; |
| import androidx.fragment.app.FragmentManager; |
| import androidx.lifecycle.LiveData; |
| import androidx.lifecycle.MutableLiveData; |
| import androidx.lifecycle.ViewModel; |
| import androidx.lifecycle.ViewModelProvider; |
| |
| import com.android.providers.media.ConfigStore; |
| import com.android.providers.media.R; |
| import com.android.providers.media.photopicker.data.PickerResult; |
| import com.android.providers.media.photopicker.data.Selection; |
| import com.android.providers.media.photopicker.data.UserIdManager; |
| import com.android.providers.media.photopicker.data.model.Item; |
| import com.android.providers.media.photopicker.data.model.UserId; |
| import com.android.providers.media.photopicker.ui.TabContainerFragment; |
| import com.android.providers.media.photopicker.util.LayoutModeUtils; |
| import com.android.providers.media.photopicker.util.MimeFilterUtils; |
| import com.android.providers.media.photopicker.viewmodel.PickerViewModel; |
| import com.android.providers.media.util.ForegroundThread; |
| |
| import com.google.android.material.bottomsheet.BottomSheetBehavior; |
| import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback; |
| import com.google.android.material.tabs.TabLayout; |
| import com.google.common.collect.Lists; |
| |
| import java.util.List; |
| |
| /** |
| * Photo Picker allows users to choose one or more photos and/or videos to share with an app. The |
| * app does not get access to all photos/videos. |
| */ |
| public class PhotoPickerActivity extends AppCompatActivity { |
| private static final String TAG = "PhotoPickerActivity"; |
| private static final float BOTTOM_SHEET_PEEK_HEIGHT_PERCENTAGE = 0.60f; |
| private static final float HIDE_PROFILE_BUTTON_THRESHOLD = -0.5f; |
| private static final String LOGGER_INSTANCE_ID_ARG = "loggerInstanceIdArg"; |
| private static final String EXTRA_PRELOAD_SELECTED = |
| "com.android.providers.media.photopicker.extra.PRELOAD_SELECTED"; |
| |
| private ViewModelProvider mViewModelProvider; |
| private PickerViewModel mPickerViewModel; |
| private PreloaderInstanceHolder mPreloaderInstanceHolder; |
| |
| private Selection mSelection; |
| private BottomSheetBehavior mBottomSheetBehavior; |
| private View mBottomBar; |
| private View mBottomSheetView; |
| private View mFragmentContainerView; |
| private View mDragBar; |
| private View mProfileButton; |
| private TextView mPrivacyText; |
| private TabLayout mTabLayout; |
| private Toolbar mToolbar; |
| private CrossProfileListeners mCrossProfileListeners; |
| |
| @NonNull |
| private final MutableLiveData<Boolean> mIsItemPhotoGridViewChanged = |
| new MutableLiveData<>(false); |
| |
| @ColorInt |
| private int mDefaultBackgroundColor; |
| |
| @ColorInt |
| private int mToolBarIconColor; |
| |
| private int mToolbarHeight = 0; |
| private boolean mShouldLogCancelledResult = true; |
| |
| @Override |
| public void onCreate(Bundle savedInstanceState) { |
| // This is required as GET_CONTENT with type "*/*" is also received by PhotoPicker due |
| // to higher priority than DocumentsUi. "*/*" mime type filter is caught as it is a superset |
| // of "image/*" and "video/*". |
| if (rerouteGetContentRequestIfRequired()) { |
| // This activity is finishing now: we should not run the setup below, |
| // BUT before we return we have to call super.onCreate() (otherwise we are we will get |
| // SuperNotCalledException: Activity did not call through to super.onCreate()) |
| super.onCreate(savedInstanceState); |
| return; |
| } |
| |
| // We use the device default theme as the base theme. Apply the material them for the |
| // material components. We use force "false" here, only values that are not already defined |
| // in the base theme will be copied. |
| getTheme().applyStyle(R.style.PickerMaterialTheme, /* force */ false); |
| |
| super.onCreate(savedInstanceState); |
| |
| setContentView(R.layout.activity_photo_picker); |
| mToolbar = findViewById(R.id.toolbar); |
| setSupportActionBar(mToolbar); |
| getSupportActionBar().setDisplayHomeAsUpEnabled(true); |
| |
| final int[] attrs = new int[]{R.attr.actionBarSize, R.attr.pickerTextColor}; |
| final TypedArray ta = obtainStyledAttributes(attrs); |
| // Save toolbar height so that we can use it as padding for FragmentContainerView |
| mToolbarHeight = ta.getDimensionPixelSize(/* index */ 0, /* defValue */ -1); |
| mToolBarIconColor = ta.getColor(/* index */ 1,/* defValue */ -1); |
| ta.recycle(); |
| |
| mDefaultBackgroundColor = getColor(R.color.picker_background_color); |
| |
| mViewModelProvider = new ViewModelProvider(this); |
| mPickerViewModel = getOrCreateViewModel(); |
| |
| final Intent intent = getIntent(); |
| try { |
| mPickerViewModel.parseValuesFromIntent(intent); |
| } catch (IllegalArgumentException e) { |
| Log.e(TAG, "Finish activity due to an exception while parsing extras", e); |
| finishWithoutLoggingCancelledResult(); |
| return; |
| } |
| mSelection = mPickerViewModel.getSelection(); |
| mDragBar = findViewById(R.id.drag_bar); |
| mPrivacyText = findViewById(R.id.privacy_text); |
| mBottomBar = findViewById(R.id.picker_bottom_bar); |
| mProfileButton = findViewById(R.id.profile_button); |
| |
| mTabLayout = findViewById(R.id.tab_layout); |
| |
| initBottomSheetBehavior(); |
| |
| // Save the fragment container layout so that we can adjust the padding based on preview or |
| // non-preview mode. |
| mFragmentContainerView = findViewById(R.id.fragment_container); |
| |
| mCrossProfileListeners = new CrossProfileListeners(); |
| |
| mPreloaderInstanceHolder = mViewModelProvider.get(PreloaderInstanceHolder.class); |
| if (mPreloaderInstanceHolder.preloader != null) { |
| subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader); |
| } |
| |
| observeRefreshUiNotificationLiveData(); |
| // Restore state operation should always be kept at the end of this method. |
| restoreState(savedInstanceState); |
| // Call this after state is restored, to use the correct LOGGER_INSTANCE_ID_ARG |
| if (savedInstanceState == null) { |
| final String intentAction = intent != null ? intent.getAction() : null; |
| mPickerViewModel.logPickerOpened(Binder.getCallingUid(), getCallingPackage(), |
| intentAction); |
| } |
| } |
| |
| @Override |
| public void onDestroy() { |
| super.onDestroy(); |
| if (mCrossProfileListeners != null) { |
| // This is required to unregister any broadcast receivers. |
| mCrossProfileListeners.onDestroy(); |
| } |
| } |
| |
| /** |
| * Warning: This method is needed for tests, we are not customizing anything here. |
| * Allowing ourselves to control ViewModel creation helps us mock the ViewModel for test. |
| */ |
| @VisibleForTesting |
| @NonNull |
| protected PickerViewModel getOrCreateViewModel() { |
| return mViewModelProvider.get(PickerViewModel.class); |
| } |
| |
| @Override |
| public boolean dispatchTouchEvent(MotionEvent event){ |
| if (event.getAction() == MotionEvent.ACTION_DOWN) { |
| if (mBottomSheetBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { |
| |
| Rect outRect = new Rect(); |
| mBottomSheetView.getGlobalVisibleRect(outRect); |
| |
| if (!outRect.contains((int)event.getRawX(), (int)event.getRawY())) { |
| mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); |
| } |
| } |
| } |
| return super.dispatchTouchEvent(event); |
| } |
| |
| /** |
| * This method is called on action bar home button clicks if |
| * {@link androidx.appcompat.app.ActionBar#setDisplayHomeAsUpEnabled(boolean)} is set |
| * {@code true}. |
| */ |
| @Override |
| public boolean onSupportNavigateUp() { |
| int backStackEntryCount = getSupportFragmentManager().getBackStackEntryCount(); |
| mPickerViewModel.logActionBarHomeButtonClick(backStackEntryCount); |
| super.onBackPressed(); |
| return true; |
| } |
| |
| @Override |
| public void onBackPressed() { |
| int backStackEntryCount = getSupportFragmentManager().getBackStackEntryCount(); |
| mPickerViewModel.logBackGestureWithStackCount(backStackEntryCount); |
| super.onBackPressed(); |
| } |
| |
| @Override |
| public boolean onMenuOpened(int featureId, Menu menu) { |
| mPickerViewModel.logMenuOpened(); |
| return super.onMenuOpened(featureId, menu); |
| } |
| |
| @Override |
| public void setTitle(CharSequence title) { |
| super.setTitle(title); |
| getSupportActionBar().setTitle(title); |
| } |
| |
| /** |
| * Called when owning activity is saving state to be used to restore state during creation. |
| * |
| * @param state Bundle to save state |
| */ |
| @Override |
| public void onSaveInstanceState(Bundle state) { |
| super.onSaveInstanceState(state); |
| saveBottomSheetState(); |
| state.putParcelable(LOGGER_INSTANCE_ID_ARG, mPickerViewModel.getInstanceId()); |
| } |
| |
| @Override |
| public boolean onCreateOptionsMenu(@NonNull Menu menu) { |
| getMenuInflater().inflate(R.menu.picker_overflow_menu, menu); |
| return true; |
| } |
| |
| @Override |
| public boolean onPrepareOptionsMenu(@NonNull Menu menu) { |
| super.onPrepareOptionsMenu(menu); |
| // All logic to hide/show an item in the menu must be in this method |
| final MenuItem settingsMenuItem = menu.findItem(R.id.settings); |
| |
| // TODO(b/195009187): Settings menu item is hidden by default till Settings page is |
| // completely developed. |
| settingsMenuItem.setVisible(shouldShowSettingsScreen()); |
| |
| // Browse menu item allows users to launch DocumentsUI. This item should only be shown if |
| // PhotoPicker was opened via {@link #ACTION_GET_CONTENT}. |
| menu.findItem(R.id.browse).setVisible(isGetContentAction()); |
| |
| return menu.hasVisibleItems(); |
| } |
| |
| @Override |
| public boolean onOptionsItemSelected(MenuItem item) { |
| switch (item.getItemId()) { |
| case R.id.browse: |
| mPickerViewModel.logBrowseToDocumentsUi(Binder.getCallingUid(), |
| getCallingPackage()); |
| launchDocumentsUiAndFinishPicker(); |
| return true; |
| case R.id.settings: |
| startSettingsActivity(); |
| return true; |
| default: |
| // Continue to return the result of base class' onOptionsItemSelected(item) |
| } |
| return super.onOptionsItemSelected(item); |
| } |
| |
| /** |
| * Launch the Photo Picker settings page where user can view/edit current cloud media provider. |
| */ |
| public void startSettingsActivity() { |
| final Intent intent = new Intent(this, PhotoPickerSettingsActivity.class); |
| intent.putExtra(EXTRA_CURRENT_USER_ID, getCurrentUserId()); |
| startActivity(intent); |
| } |
| |
| /** |
| * @return {@code true} if the intent was re-routed to the DocumentsUI (and this |
| * {@code PhotoPickerActivity} is {@link #isFinishing()} now). {@code false} - otherwise. |
| */ |
| private boolean rerouteGetContentRequestIfRequired() { |
| final Intent intent = getIntent(); |
| if (!ACTION_GET_CONTENT.equals(intent.getAction())) { |
| return false; |
| } |
| |
| // TODO(b/232775643): Workaround to support PhotoPicker invoked from DocumentsUi. |
| // GET_CONTENT for all (media and non-media) files opens DocumentsUi, but it still shows |
| // "Photo Picker app option. When the user clicks on "Photo Picker", the same intent which |
| // includes filters to show non-media files as well is forwarded to PhotoPicker. |
| // Make sure Photo Picker is opened when the intent is explicitly forwarded by documentsUi |
| if (isIntentReferredByDocumentsUi(getReferrer())) { |
| Log.i(TAG, "Open PhotoPicker when a forwarded ACTION_GET_CONTENT intent is received"); |
| return false; |
| } |
| |
| // Check if we can handle the specified MIME types. |
| // If we can - do not reroute and thus return false. |
| if (!MimeFilterUtils.requiresUnsupportedFilters(intent)) return false; |
| |
| launchDocumentsUiAndFinishPicker(); |
| return true; |
| } |
| |
| private boolean isIntentReferredByDocumentsUi(Uri referrerAppUri) { |
| ComponentName documentsUiComponentName = getDocumentsUiComponentName(this); |
| String documentsUiPackageName = documentsUiComponentName != null |
| ? documentsUiComponentName.getPackageName() : null; |
| return referrerAppUri != null && referrerAppUri.getHost().equals(documentsUiPackageName); |
| } |
| |
| private void launchDocumentsUiAndFinishPicker() { |
| Log.i(TAG, "Launch DocumentsUI and finish picker"); |
| |
| startActivityAsUser(getDocumentsUiForwardingIntent(this, getIntent()), |
| UserId.CURRENT_USER.getUserHandle()); |
| // RESULT_CANCELLED is not returned to the calling app as the DocumentsUi result will be |
| // returned. We don't have to log as this flow can be called in 2 cases: |
| // 1. GET_CONTENT had non-media filters, so the user or the app should be unaffected as they |
| // see that DocumentsUi was opened directly. |
| // 2. User clicked on "Browse.." button, in that case we already log that event separately. |
| finishWithoutLoggingCancelledResult(); |
| } |
| |
| @VisibleForTesting |
| static Intent getDocumentsUiForwardingIntent(Context context, Intent intent) { |
| intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT); |
| intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); |
| intent.setComponent(getDocumentsUiComponentName(context)); |
| return intent; |
| } |
| |
| private static ComponentName getDocumentsUiComponentName(Context context) { |
| final PackageManager pm = context.getPackageManager(); |
| // DocumentsUI is the default handler for ACTION_OPEN_DOCUMENT |
| final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); |
| intent.addCategory(Intent.CATEGORY_OPENABLE); |
| intent.setType("*/*"); |
| return intent.resolveActivity(pm); |
| } |
| |
| private void restoreState(Bundle savedInstanceState) { |
| if (savedInstanceState != null) { |
| restoreBottomSheetState(); |
| mPickerViewModel.setInstanceId( |
| savedInstanceState.getParcelable(LOGGER_INSTANCE_ID_ARG)); |
| } else { |
| setupInitialLaunchState(); |
| } |
| } |
| |
| /** |
| * Sets up states for the initial launch. This includes updating common layouts, selecting |
| * Photos tab item and saving the current bottom sheet state for later. |
| */ |
| private void setupInitialLaunchState() { |
| updateCommonLayouts(MODE_PHOTOS_TAB, /* title */ ""); |
| TabContainerFragment.show(getSupportFragmentManager()); |
| saveBottomSheetState(); |
| } |
| |
| private void initBottomSheetBehavior() { |
| mBottomSheetView = findViewById(R.id.bottom_sheet); |
| mBottomSheetBehavior = BottomSheetBehavior.from(mBottomSheetView); |
| initStateForBottomSheet(); |
| |
| mBottomSheetBehavior.addBottomSheetCallback(createBottomSheetCallBack()); |
| setRoundedCornersForBottomSheet(); |
| } |
| |
| private BottomSheetCallback createBottomSheetCallBack() { |
| return new BottomSheetCallback() { |
| private boolean mIsHiddenDueToBottomSheetClosing = false; |
| @Override |
| public void onStateChanged(@NonNull View bottomSheet, int newState) { |
| if (newState == BottomSheetBehavior.STATE_HIDDEN) { |
| mPickerViewModel.logSwipeDownExit(); |
| finish(); |
| } else if (newState == BottomSheetBehavior.STATE_EXPANDED) { |
| mPickerViewModel.logExpandToFullScreen(); |
| } |
| saveBottomSheetState(); |
| } |
| |
| @Override |
| public void onSlide(@NonNull View bottomSheet, float slideOffset) { |
| // slideOffset = -1 is when bottomsheet is completely hidden |
| // slideOffset = 0 is when bottomsheet is in collapsed mode |
| // slideOffset = 1 is when bottomsheet is in expanded mode |
| // We hide the Profile button if the bottomsheet is 50% in between collapsed state |
| // and hidden state. |
| if (slideOffset < HIDE_PROFILE_BUTTON_THRESHOLD && |
| mProfileButton.getVisibility() == View.VISIBLE) { |
| mProfileButton.setVisibility(View.GONE); |
| mIsHiddenDueToBottomSheetClosing = true; |
| return; |
| } |
| |
| // We need to handle this state if the user is swiping till the bottom of the |
| // screen but then swipes up bottom sheet suddenly |
| if (slideOffset > HIDE_PROFILE_BUTTON_THRESHOLD && |
| mIsHiddenDueToBottomSheetClosing) { |
| mProfileButton.setVisibility(View.VISIBLE); |
| mIsHiddenDueToBottomSheetClosing = false; |
| } |
| } |
| }; |
| } |
| |
| private void setRoundedCornersForBottomSheet() { |
| final float cornerRadius = |
| getResources().getDimensionPixelSize(R.dimen.picker_top_corner_radius); |
| final ViewOutlineProvider viewOutlineProvider = new ViewOutlineProvider() { |
| @Override |
| public void getOutline(final View view, final Outline outline) { |
| outline.setRoundRect(0, 0, view.getWidth(), |
| (int)(view.getHeight() + cornerRadius), cornerRadius); |
| } |
| }; |
| mBottomSheetView.setOutlineProvider(viewOutlineProvider); |
| } |
| |
| private void initStateForBottomSheet() { |
| if (!isAccessibilityEnabled() && !mSelection.canSelectMultiple() |
| && !isOrientationLandscape()) { |
| final int peekHeight = getBottomSheetPeekHeight(this); |
| mBottomSheetBehavior.setPeekHeight(peekHeight); |
| mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); |
| } else { |
| mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); |
| mBottomSheetBehavior.setSkipCollapsed(true); |
| } |
| } |
| |
| /** |
| * Warning: This method is visible for espresso tests, we are not customizing anything here. |
| * Allowing ourselves to control the accessibility state helps us mock it for these tests. |
| */ |
| @VisibleForTesting |
| protected boolean isAccessibilityEnabled() { |
| return getSystemService(AccessibilityManager.class).isEnabled(); |
| } |
| |
| private static int getBottomSheetPeekHeight(Context context) { |
| final WindowManager windowManager = context.getSystemService(WindowManager.class); |
| final Rect displayBounds = windowManager.getCurrentWindowMetrics().getBounds(); |
| return (int) (displayBounds.height() * BOTTOM_SHEET_PEEK_HEIGHT_PERCENTAGE); |
| } |
| |
| private void restoreBottomSheetState() { |
| // BottomSheet is always EXPANDED for landscape |
| if (isOrientationLandscape()) { |
| return; |
| } |
| final int savedState = mPickerViewModel.getBottomSheetState(); |
| if (isValidBottomSheetState(savedState)) { |
| mBottomSheetBehavior.setState(savedState); |
| } |
| } |
| |
| private void saveBottomSheetState() { |
| // Do not save state for landscape or preview mode. This is because they are always in |
| // STATE_EXPANDED state. |
| if (isOrientationLandscape() || !mBottomSheetView.getClipToOutline()) { |
| return; |
| } |
| mPickerViewModel.setBottomSheetState(mBottomSheetBehavior.getState()); |
| } |
| |
| private boolean isValidBottomSheetState(int state) { |
| return state == BottomSheetBehavior.STATE_COLLAPSED || |
| state == BottomSheetBehavior.STATE_EXPANDED; |
| } |
| |
| private boolean isOrientationLandscape() { |
| return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; |
| } |
| |
| public LiveData<Boolean> isItemPhotoGridViewChanged() { |
| return mIsItemPhotoGridViewChanged; |
| } |
| |
| public void setResultAndFinishSelf() { |
| logPickerSelectionConfirmed(mSelection.getSelectedItems().size()); |
| if (shouldPreloadSelectedItems()) { |
| final var uris = PickerResult.getPickerUrisForItems( |
| mSelection.getSelectedItems()); |
| mPickerViewModel.logPreloadingStarted(uris.size()); |
| mPreloaderInstanceHolder.preloader = |
| SelectedMediaPreloader.preload(/* activity */ this, uris); |
| deSelectUnavailableMedia(mPreloaderInstanceHolder.preloader); |
| subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader); |
| } else { |
| setResultAndFinishSelfInternal(); |
| } |
| } |
| |
| private void setResultAndFinishSelfInternal() { |
| // In addition to the activity result, add the selected files to the MediaProvider |
| // media_grants database. |
| if (isUserSelectImagesForAppAction()) { |
| setResultForUserSelectImagesForAppAction(); |
| } else { |
| setResultForPickImagesOrGetContentAction(); |
| } |
| |
| finishWithoutLoggingCancelledResult(); |
| } |
| |
| private void setResultForUserSelectImagesForAppAction() { |
| // Since Photopicker is in permission mode, don't send back URI grants. |
| setResult(RESULT_OK); |
| // The permission controller will pass the requesting package's UID here |
| final Bundle extras = getIntent().getExtras(); |
| final int uid = extras.getInt(Intent.EXTRA_UID); |
| final List<Uri> uris = getPickerUrisForItems(mSelection.getSelectedItemsWithoutGrants()); |
| if (!uris.isEmpty()) { |
| ForegroundThread.getExecutor().execute(() -> { |
| // Handle grants in another thread to not block the UI. |
| grantMediaReadForPackage(getApplicationContext(), uid, uris); |
| mPickerViewModel.logPickerChoiceAddedGrantsCount(uris.size(), extras); |
| }); |
| } |
| |
| // Revoke READ_GRANT for items that were pre-granted but now in the current session user has |
| // deselected them. |
| if (mPickerViewModel.isManagedSelectionEnabled()) { |
| final List<Uri> urisForItemsWhoseGrantsNeedsToBeRevoked = getPickerUrisForItems( |
| mSelection.getPreGrantedItemsToBeRevoked()); |
| if (!urisForItemsWhoseGrantsNeedsToBeRevoked.isEmpty()) { |
| ForegroundThread.getExecutor().execute(() -> { |
| // Handle grants in another thread to not block the UI. |
| MediaStore.revokeMediaReadForPackages(getApplicationContext(), uid, |
| urisForItemsWhoseGrantsNeedsToBeRevoked); |
| mPickerViewModel.logPickerChoiceRevokedGrantsCount( |
| urisForItemsWhoseGrantsNeedsToBeRevoked.size(), extras); |
| }); |
| } |
| } |
| } |
| |
| private void setResultForPickImagesOrGetContentAction() { |
| final Intent resultData = getPickerResponseIntent( |
| mSelection.canSelectMultiple(), |
| mSelection.getSelectedItems()); |
| setResult(RESULT_OK, resultData); |
| } |
| |
| private boolean shouldPreloadSelectedItems() { |
| // Only preload if the cloud media may be shown in the PhotoPicker. |
| if (!isCloudMediaAvailable()) { |
| return false; |
| } |
| |
| final boolean isGetContent = isGetContentAction(); |
| final boolean isPickImages = isPickImagesAction(); |
| final ConfigStore cs = mPickerViewModel.getConfigStore(); |
| |
| if (getIntent().hasExtra(EXTRA_PRELOAD_SELECTED)) { |
| if (Build.isDebuggable() |
| || (isPickImages && cs.shouldPickerRespectPreloadArgumentForPickImages())) { |
| return getIntent().getBooleanExtra(EXTRA_PRELOAD_SELECTED, |
| /* default, not used */ false); |
| } |
| } |
| |
| if (isGetContent) { |
| return cs.shouldPickerPreloadForGetContent(); |
| } else if (isPickImages) { |
| return cs.shouldPickerPreloadForPickImages(); |
| } else { |
| Log.w(TAG, "Not preloading selection for \"" + getIntent().getAction() + "\" action"); |
| return false; |
| } |
| } |
| |
| private void subscribeToSelectedMediaPreloader(@NonNull SelectedMediaPreloader preloader) { |
| preloader.getIsFinishedLiveData().observe( |
| /* lifecycleOwner */ PhotoPickerActivity.this, |
| isFinished -> { |
| if (isFinished) { |
| mPickerViewModel.logPreloadingFinished(); |
| setResultAndFinishSelfInternal(); |
| } |
| }); |
| } |
| |
| // This method is responsible for deselecting all unavailable items from selection list |
| // when user tries selecting unavailable could only media (not cached) while offline |
| private void deSelectUnavailableMedia(@NonNull SelectedMediaPreloader preloader) { |
| preloader.getUnavailableMediaIndexes().observe( |
| /* lifecycleOwner */ PhotoPickerActivity.this, |
| unavailableMediaIndexes -> { |
| if (unavailableMediaIndexes.size() > 0) { |
| // To notify the fragment to uncheck the unavailable items at UI those are |
| // no longer available in the selection list. |
| mIsItemPhotoGridViewChanged.postValue(true); |
| |
| // Checking if preloading was intentionally be cancelled by the user |
| if (unavailableMediaIndexes.get(unavailableMediaIndexes.size() - 1) != -1) { |
| // Displaying error dialog with an error message when the user tries |
| // to add unavailable cloud only media (not cached) while offline. |
| DialogUtils.showDialog(this, |
| getResources().getString(R.string.dialog_error_title), |
| getResources().getString(R.string.dialog_error_message)); |
| mPickerViewModel.logPreloadingFailed(unavailableMediaIndexes.size()); |
| } else { |
| unavailableMediaIndexes.remove( |
| unavailableMediaIndexes.size() - 1); |
| mPickerViewModel.logPreloadingCancelled(unavailableMediaIndexes.size()); |
| } |
| List<Item> selectedItems = mSelection.getSelectedItems(); |
| for (var mediaIndex : unavailableMediaIndexes) { |
| mSelection.removeSelectedItem(selectedItems.get(mediaIndex)); |
| } |
| } |
| }); |
| } |
| |
| /** |
| * NOTE: this may wrongly return {@code false} if called before {@link PickerViewModel} had a |
| * chance to fetch the authority and the account of the current |
| * {@link android.provider.CloudMediaProvider}. |
| * However, this may only happen very early on in the lifecycle. |
| */ |
| private boolean isCloudMediaAvailable() { |
| return mPickerViewModel.getCloudMediaProviderAuthorityLiveData().getValue() != null |
| && mPickerViewModel.getCloudMediaAccountNameLiveData().getValue() != null; |
| } |
| |
| /** |
| * This should be called if: |
| * * We are finishing Picker explicitly before the user has seen PhotoPicker UI due to known |
| * checks/workflow. |
| * * We are not returning {@link Activity#RESULT_CANCELED} |
| */ |
| private void finishWithoutLoggingCancelledResult() { |
| mShouldLogCancelledResult = false; |
| finish(); |
| } |
| |
| @Override |
| public void finish() { |
| if (mShouldLogCancelledResult) { |
| logPickerCancelled(); |
| } |
| super.finish(); |
| } |
| |
| private void logPickerSelectionConfirmed(int countOfItemsConfirmed) { |
| mPickerViewModel.logPickerConfirm(Binder.getCallingUid(), getCallingPackage(), |
| countOfItemsConfirmed); |
| } |
| |
| private void logPickerCancelled() { |
| mPickerViewModel.logPickerCancel(Binder.getCallingUid(), getCallingPackage()); |
| } |
| |
| @UserIdInt |
| private int getCurrentUserId() { |
| final UserIdManager userIdManager = mPickerViewModel.getUserIdManager(); |
| return userIdManager.getCurrentUserProfileId().getIdentifier(); |
| } |
| |
| /** |
| * Updates the common views such as Title, Toolbar, Navigation bar, status bar and bottom sheet |
| * behavior |
| * |
| * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update. |
| * @param title the title to set for the Activity |
| */ |
| public void updateCommonLayouts(LayoutModeUtils.Mode mode, String title) { |
| updateTitle(title); |
| updateToolbar(mode); |
| updateStatusBarAndNavigationBar(mode); |
| updateBottomSheetBehavior(mode); |
| updateFragmentContainerViewPadding(mode); |
| updateDragBarVisibility(mode); |
| updateHeaderTextVisibility(mode); |
| // The bottom bar and profile button are not shown on preview, hide them in preview. We |
| // handle the visibility of them in TabFragment. We don't need to make them shown in |
| // non-preview page here. |
| if (mode.isPreview) { |
| mBottomBar.setVisibility(View.GONE); |
| mProfileButton.setVisibility(View.GONE); |
| } |
| } |
| |
| private void updateTitle(String title) { |
| setTitle(title); |
| } |
| |
| /** |
| * Updates the icons and show/hide the tab layout with {@code mode}. |
| * |
| * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update. |
| */ |
| private void updateToolbar(@NonNull LayoutModeUtils.Mode mode) { |
| final boolean isPreview = mode.isPreview; |
| final boolean shouldShowTabLayout = mode.isPhotosTabOrAlbumsTab; |
| // 1. Set the tabLayout visibility |
| mTabLayout.setVisibility(shouldShowTabLayout ? View.VISIBLE : View.GONE); |
| |
| // 2. Set the toolbar color |
| final ColorDrawable toolbarColor; |
| if (isPreview && !shouldShowTabLayout) { |
| if (isOrientationLandscape()) { |
| // Toolbar in Preview will have transparent color in Landscape mode. |
| toolbarColor = new ColorDrawable(getColor(android.R.color.transparent)); |
| } else { |
| // Toolbar in Preview will have a solid color with 90% opacity in Portrait mode. |
| toolbarColor = new ColorDrawable(getColor(R.color.preview_scrim_solid_color)); |
| } |
| } else { |
| toolbarColor = new ColorDrawable(mDefaultBackgroundColor); |
| } |
| getSupportActionBar().setBackgroundDrawable(toolbarColor); |
| |
| // 3. Set the toolbar icon. |
| final Drawable icon; |
| if (shouldShowTabLayout) { |
| icon = getDrawable(R.drawable.ic_close); |
| } else { |
| icon = getDrawable(R.drawable.ic_arrow_back); |
| // Preview mode has dark background, hence icons will be WHITE in color |
| icon.setTint(isPreview ? Color.WHITE : mToolBarIconColor); |
| } |
| getSupportActionBar().setHomeAsUpIndicator(icon); |
| getSupportActionBar().setHomeActionContentDescription( |
| shouldShowTabLayout ? android.R.string.cancel |
| : R.string.abc_action_bar_up_description); |
| if (mToolbar.getOverflowIcon() != null) { |
| mToolbar.getOverflowIcon().setTint(isPreview ? Color.WHITE : mToolBarIconColor); |
| } |
| } |
| |
| /** |
| * Updates status bar and navigation bar |
| * |
| * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update. |
| */ |
| private void updateStatusBarAndNavigationBar(@NonNull LayoutModeUtils.Mode mode) { |
| final boolean isPreview = mode.isPreview; |
| final int navigationBarColor = isPreview ? getColor(R.color.preview_background_color) : |
| mDefaultBackgroundColor; |
| getWindow().setNavigationBarColor(navigationBarColor); |
| |
| final int statusBarColor = isPreview ? getColor(R.color.preview_background_color) : |
| getColor(android.R.color.transparent); |
| getWindow().setStatusBarColor(statusBarColor); |
| |
| // Update the system bar appearance |
| final int mask = WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; |
| int appearance = 0; |
| if (!isPreview) { |
| final int uiModeNight = |
| getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; |
| |
| if (uiModeNight == Configuration.UI_MODE_NIGHT_NO) { |
| // If the system is not in Dark theme, set the system bars to light mode. |
| appearance = mask; |
| } |
| } |
| getWindow().getInsetsController().setSystemBarsAppearance(appearance, mask); |
| } |
| |
| /** |
| * Updates the bottom sheet behavior |
| * |
| * @param mode {@link LayoutModeUtils.Mode} which describes the layout mode to update. |
| */ |
| private void updateBottomSheetBehavior(@NonNull LayoutModeUtils.Mode mode) { |
| final boolean isPreview = mode.isPreview; |
| if (mBottomSheetView != null) { |
| mBottomSheetView.setClipToOutline(!isPreview); |
| // TODO(b/197241815): Add animation downward swipe for preview should go back to |
| // the photo in photos grid |
| mBottomSheetBehavior.setDraggable(!isPreview); |
| } |
| if (isPreview) { |
| if (mBottomSheetBehavior.getState() != BottomSheetBehavior.STATE_EXPANDED) { |
| // Sets bottom sheet behavior state to STATE_EXPANDED if it's not already expanded. |
| // This is useful when user goes to Preview mode which is always Full screen. |
| // TODO(b/197241815): Add animation preview to full screen and back transition to |
| // partial screen. This is similar to long press animation. |
| mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); |
| } |
| } else { |
| restoreBottomSheetState(); |
| } |
| } |
| |
| /** |
| * Updates the FragmentContainerView padding. |
| * <p> |
| * For Preview mode, toolbar overlaps the Fragment content, hence the padding will be set to 0. |
| * For Non-Preview mode, toolbar doesn't overlap the contents of the fragment, hence we set the |
| * padding as the height of the toolbar. |
| */ |
| private void updateFragmentContainerViewPadding(@NonNull LayoutModeUtils.Mode mode) { |
| if (mFragmentContainerView == null) return; |
| |
| final int topPadding; |
| if (mode.isPreview) { |
| topPadding = 0; |
| } else { |
| topPadding = mToolbarHeight; |
| } |
| |
| mFragmentContainerView.setPadding(mFragmentContainerView.getPaddingLeft(), |
| topPadding, mFragmentContainerView.getPaddingRight(), |
| mFragmentContainerView.getPaddingBottom()); |
| } |
| |
| private void updateDragBarVisibility(@NonNull LayoutModeUtils.Mode mode) { |
| final boolean shouldShowDragBar = !mode.isPreview; |
| mDragBar.setVisibility(shouldShowDragBar ? View.VISIBLE : View.GONE); |
| } |
| |
| private void updateHeaderTextVisibility(@NonNull LayoutModeUtils.Mode mode) { |
| // The privacy text is only shown on the Photos tab and Albums tab when not in |
| // permission select mode. |
| final boolean shouldShowPrivacyMessage = mode.isPhotosTabOrAlbumsTab; |
| |
| if (!shouldShowPrivacyMessage) { |
| mPrivacyText.setVisibility(View.GONE); |
| return; |
| } |
| |
| if (mPickerViewModel.isUserSelectForApp()) { |
| mPrivacyText.setText(R.string.picker_header_permissions); |
| mPrivacyText.setTextSize( |
| TypedValue.COMPLEX_UNIT_PX, |
| getResources().getDimension(R.dimen.picker_user_select_header_text_size)); |
| } else { |
| mPrivacyText.setText(R.string.picker_privacy_message); |
| mPrivacyText.setTextSize( |
| TypedValue.COMPLEX_UNIT_PX, |
| getResources().getDimension(R.dimen.picker_privacy_text_size)); |
| } |
| |
| mPrivacyText.setVisibility(View.VISIBLE); |
| } |
| |
| /** |
| * Reset to Photo Picker initial launch state (Photos grid tab) in personal profile mode. |
| */ |
| private void resetToPersonalProfile() { |
| // Clear all the fragments in the FragmentManager |
| final FragmentManager fragmentManager = getSupportFragmentManager(); |
| fragmentManager.popBackStackImmediate(/* name */ null, |
| FragmentManager.POP_BACK_STACK_INCLUSIVE); |
| |
| // Reset all content to the personal profile |
| mPickerViewModel.resetToPersonalProfile(); |
| |
| // Set up the fragments same as the initial launch state |
| setupInitialLaunchState(); |
| } |
| |
| /** |
| * Reset to Photo Picker initial launch state (Photos grid tab) in the current profile mode. |
| */ |
| private void resetInCurrentProfile() { |
| // Clear all the fragments in the FragmentManager |
| final FragmentManager fragmentManager = getSupportFragmentManager(); |
| fragmentManager.popBackStackImmediate(/* name */ null, |
| FragmentManager.POP_BACK_STACK_INCLUSIVE); |
| |
| // Reset all content in the current profile |
| mPickerViewModel.resetAllContentInCurrentProfile(); |
| |
| // Set up the fragments same as the initial launch state |
| setupInitialLaunchState(); |
| } |
| |
| /** |
| * Returns {@code true} if settings page is enabled. |
| */ |
| private boolean shouldShowSettingsScreen() { |
| if (mPickerViewModel.shouldShowOnlyLocalFeatures()) { |
| return false; |
| } |
| |
| final ComponentName componentName = new ComponentName(this, |
| PhotoPickerSettingsActivity.class); |
| return getPackageManager().getComponentEnabledSetting(componentName) |
| == PackageManager.COMPONENT_ENABLED_STATE_ENABLED; |
| } |
| |
| /** |
| * Returns {@code true} if intent action is {@link ACTION_GET_CONTENT}. |
| */ |
| private boolean isGetContentAction() { |
| return ACTION_GET_CONTENT.equals(getIntent().getAction()); |
| } |
| |
| /** |
| * Returns {@code true} if intent action is {@link ACTION_PICK_IMAGES}. |
| */ |
| private boolean isPickImagesAction() { |
| return ACTION_PICK_IMAGES.equals(getIntent().getAction()); |
| } |
| |
| /** |
| * Returns {@code true} if intent action is {@link ACTION_USER_SELECT_IMAGES_FOR_APP} |
| * (the 3-way storage permission grant flow) |
| */ |
| private boolean isUserSelectImagesForAppAction() { |
| return ACTION_USER_SELECT_IMAGES_FOR_APP.equals(getIntent().getAction()); |
| } |
| |
| private class CrossProfileListeners { |
| |
| private final List<String> MANAGED_PROFILE_FILTER_ACTIONS = Lists.newArrayList( |
| Intent.ACTION_MANAGED_PROFILE_ADDED, // add profile button switch |
| Intent.ACTION_MANAGED_PROFILE_REMOVED, // remove profile button switch |
| Intent.ACTION_MANAGED_PROFILE_UNLOCKED, // activate profile button switch |
| Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE // disable profile button switch |
| ); |
| |
| private final UserIdManager mUserIdManager; |
| |
| public CrossProfileListeners() { |
| mUserIdManager = mPickerViewModel.getUserIdManager(); |
| |
| registerBroadcastReceivers(); |
| } |
| |
| public void onDestroy() { |
| unregisterReceiver(mReceiver); |
| } |
| |
| private final BroadcastReceiver mReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| final String action = intent.getAction(); |
| |
| final UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER); |
| final UserId userId = UserId.of(userHandle); |
| |
| // We only need to refresh the layout when the received profile user is the |
| // managed user corresponding to the current profile or a new work profile is added |
| // for the current user. |
| if (!userId.equals(mUserIdManager.getManagedUserId()) && |
| !action.equals(Intent.ACTION_MANAGED_PROFILE_ADDED)) { |
| return; |
| } |
| |
| switch (action) { |
| case Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE: |
| handleWorkProfileOff(); |
| break; |
| case Intent.ACTION_MANAGED_PROFILE_REMOVED: |
| handleWorkProfileRemoved(); |
| break; |
| case Intent.ACTION_MANAGED_PROFILE_UNLOCKED: |
| handleWorkProfileOn(); |
| break; |
| case Intent.ACTION_MANAGED_PROFILE_ADDED: |
| handleWorkProfileAdded(); |
| break; |
| default: |
| // do nothing |
| } |
| } |
| }; |
| |
| private void registerBroadcastReceivers() { |
| final IntentFilter managedProfileFilter = new IntentFilter(); |
| for (String managedProfileAction : MANAGED_PROFILE_FILTER_ACTIONS) { |
| managedProfileFilter.addAction(managedProfileAction); |
| } |
| registerReceiver(mReceiver, managedProfileFilter); |
| } |
| |
| private void handleWorkProfileOff() { |
| if (mUserIdManager.isManagedUserSelected()) { |
| switchToPersonalProfileInitialLaunchState(); |
| } |
| mUserIdManager.updateWorkProfileOffValue(); |
| } |
| |
| private void handleWorkProfileRemoved() { |
| if (mUserIdManager.isManagedUserSelected()) { |
| switchToPersonalProfileInitialLaunchState(); |
| } |
| mUserIdManager.resetUserIds(); |
| } |
| |
| private void handleWorkProfileAdded() { |
| mUserIdManager.resetUserIds(); |
| } |
| |
| private void handleWorkProfileOn() { |
| // Update UI for switch to profile button |
| // When the managed profile becomes available, the provider may not be available |
| // immediately, we need to check if it is ready before we reload the content. |
| mUserIdManager.waitForMediaProviderToBeAvailable(); |
| } |
| |
| private void switchToPersonalProfileInitialLaunchState() { |
| // We reset the state of the PhotoPicker as we do not want to make any |
| // assumptions on the state of the PhotoPicker when it was in Work Profile mode. |
| resetToPersonalProfile(); |
| } |
| } |
| |
| /** |
| * A {@link ViewModel} class only responsible for keeping track of "active" |
| * {@link SelectedMediaPreloader} instance (if any). |
| * This class has to be public, since somewhere in {@link ViewModelProvider} it will try to use |
| * reflection to create an instance of this class. |
| */ |
| public static class PreloaderInstanceHolder extends ViewModel { |
| @Nullable |
| SelectedMediaPreloader preloader; |
| } |
| |
| /** |
| * Reset the Picker view model content when launched with cloud features and notified to |
| * refresh the UI. |
| */ |
| private void observeRefreshUiNotificationLiveData() { |
| mPickerViewModel.shouldRefreshUiLiveData() |
| .observe(this, shouldRefresh -> { |
| if (shouldRefresh && !mPickerViewModel.shouldShowOnlyLocalFeatures()) { |
| resetInCurrentProfile(); |
| } |
| }); |
| } |
| } |