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