1 /* 2 * Copyright (C) 2019 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 package com.android.intentresolver; 17 18 import android.annotation.IntDef; 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.annotation.UserIdInt; 22 import android.app.AppGlobals; 23 import android.content.ContentResolver; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.pm.IPackageManager; 27 import android.os.Trace; 28 import android.os.UserHandle; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.widget.Button; 32 import android.widget.TextView; 33 34 import androidx.viewpager.widget.PagerAdapter; 35 import androidx.viewpager.widget.ViewPager; 36 37 import com.android.internal.annotations.VisibleForTesting; 38 39 import java.util.HashSet; 40 import java.util.List; 41 import java.util.Objects; 42 import java.util.Set; 43 import java.util.function.Supplier; 44 45 /** 46 * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for 47 * intent resolution (including share sheet). 48 */ 49 public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { 50 51 private static final String TAG = "AbstractMultiProfilePagerAdapter"; 52 static final int PROFILE_PERSONAL = 0; 53 static final int PROFILE_WORK = 1; 54 55 @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) 56 @interface Profile {} 57 58 private final Context mContext; 59 private int mCurrentPage; 60 private OnProfileSelectedListener mOnProfileSelectedListener; 61 62 private Set<Integer> mLoadedPages; 63 private final EmptyStateProvider mEmptyStateProvider; 64 private final UserHandle mWorkProfileUserHandle; 65 private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. 66 AbstractMultiProfilePagerAdapter( Context context, int currentPage, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle)67 AbstractMultiProfilePagerAdapter( 68 Context context, 69 int currentPage, 70 EmptyStateProvider emptyStateProvider, 71 Supplier<Boolean> workProfileQuietModeChecker, 72 UserHandle workProfileUserHandle) { 73 mContext = Objects.requireNonNull(context); 74 mCurrentPage = currentPage; 75 mLoadedPages = new HashSet<>(); 76 mWorkProfileUserHandle = workProfileUserHandle; 77 mEmptyStateProvider = emptyStateProvider; 78 mWorkProfileQuietModeChecker = workProfileQuietModeChecker; 79 } 80 setOnProfileSelectedListener(OnProfileSelectedListener listener)81 void setOnProfileSelectedListener(OnProfileSelectedListener listener) { 82 mOnProfileSelectedListener = listener; 83 } 84 getContext()85 Context getContext() { 86 return mContext; 87 } 88 89 /** 90 * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets 91 * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed 92 * page and rebuilds the list. 93 */ setupViewPager(ViewPager viewPager)94 void setupViewPager(ViewPager viewPager) { 95 viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { 96 @Override 97 public void onPageSelected(int position) { 98 mCurrentPage = position; 99 if (!mLoadedPages.contains(position)) { 100 rebuildActiveTab(true); 101 mLoadedPages.add(position); 102 } 103 if (mOnProfileSelectedListener != null) { 104 mOnProfileSelectedListener.onProfileSelected(position); 105 } 106 } 107 108 @Override 109 public void onPageScrollStateChanged(int state) { 110 if (mOnProfileSelectedListener != null) { 111 mOnProfileSelectedListener.onProfilePageStateChanged(state); 112 } 113 } 114 }); 115 viewPager.setAdapter(this); 116 viewPager.setCurrentItem(mCurrentPage); 117 mLoadedPages.add(mCurrentPage); 118 } 119 clearInactiveProfileCache()120 void clearInactiveProfileCache() { 121 if (mLoadedPages.size() == 1) { 122 return; 123 } 124 mLoadedPages.remove(1 - mCurrentPage); 125 } 126 127 @Override instantiateItem(ViewGroup container, int position)128 public ViewGroup instantiateItem(ViewGroup container, int position) { 129 final ProfileDescriptor profileDescriptor = getItem(position); 130 container.addView(profileDescriptor.rootView); 131 return profileDescriptor.rootView; 132 } 133 134 @Override destroyItem(ViewGroup container, int position, Object view)135 public void destroyItem(ViewGroup container, int position, Object view) { 136 container.removeView((View) view); 137 } 138 139 @Override getCount()140 public int getCount() { 141 return getItemCount(); 142 } 143 getCurrentPage()144 protected int getCurrentPage() { 145 return mCurrentPage; 146 } 147 148 @VisibleForTesting getCurrentUserHandle()149 public UserHandle getCurrentUserHandle() { 150 return getActiveListAdapter().getUserHandle(); 151 } 152 153 @Override isViewFromObject(View view, Object object)154 public boolean isViewFromObject(View view, Object object) { 155 return view == object; 156 } 157 158 @Override getPageTitle(int position)159 public CharSequence getPageTitle(int position) { 160 return null; 161 } 162 163 /** 164 * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>. 165 * <ul> 166 * <li>For a device with only one user, <code>pageIndex</code> value of 167 * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li> 168 * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would 169 * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of 170 * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> 171 * </ul> 172 */ getItem(int pageIndex)173 abstract ProfileDescriptor getItem(int pageIndex); 174 getEmptyStateView(int pageIndex)175 protected ViewGroup getEmptyStateView(int pageIndex) { 176 return getItem(pageIndex).getEmptyStateView(); 177 } 178 179 /** 180 * Returns the number of {@link ProfileDescriptor} objects. 181 * <p>For a normal consumer device with only one user returns <code>1</code>. 182 * <p>For a device with a work profile returns <code>2</code>. 183 */ getItemCount()184 abstract int getItemCount(); 185 186 /** 187 * Performs view-related initialization procedures for the adapter specified 188 * by <code>pageIndex</code>. 189 */ setupListAdapter(int pageIndex)190 abstract void setupListAdapter(int pageIndex); 191 192 /** 193 * Returns the adapter of the list view for the relevant page specified by 194 * <code>pageIndex</code>. 195 * <p>This method is meant to be implemented with an implementation-specific return type 196 * depending on the adapter type. 197 */ 198 @VisibleForTesting getAdapterForIndex(int pageIndex)199 public abstract Object getAdapterForIndex(int pageIndex); 200 201 /** 202 * Returns the {@link ResolverListAdapter} instance of the profile that represents 203 * <code>userHandle</code>. If there is no such adapter for the specified 204 * <code>userHandle</code>, returns {@code null}. 205 * <p>For example, if there is a work profile on the device with user id 10, calling this method 206 * with <code>UserHandle.of(10)</code> returns the work profile {@link ResolverListAdapter}. 207 */ 208 @Nullable getListAdapterForUserHandle(UserHandle userHandle)209 abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle); 210 211 /** 212 * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible 213 * to the user. 214 * <p>For example, if the user is viewing the work tab in the share sheet, this method returns 215 * the work profile {@link ResolverListAdapter}. 216 * @see #getInactiveListAdapter() 217 */ 218 @VisibleForTesting getActiveListAdapter()219 public abstract ResolverListAdapter getActiveListAdapter(); 220 221 /** 222 * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance 223 * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns 224 * {@code null}. 225 * <p>For example, if the user is viewing the work tab in the share sheet, this method returns 226 * the personal profile {@link ResolverListAdapter}. 227 * @see #getActiveListAdapter() 228 */ 229 @VisibleForTesting getInactiveListAdapter()230 public abstract @Nullable ResolverListAdapter getInactiveListAdapter(); 231 getPersonalListAdapter()232 public abstract ResolverListAdapter getPersonalListAdapter(); 233 getWorkListAdapter()234 public abstract @Nullable ResolverListAdapter getWorkListAdapter(); 235 getCurrentRootAdapter()236 abstract Object getCurrentRootAdapter(); 237 getActiveAdapterView()238 abstract ViewGroup getActiveAdapterView(); 239 getInactiveAdapterView()240 abstract @Nullable ViewGroup getInactiveAdapterView(); 241 242 /** 243 * Rebuilds the tab that is currently visible to the user. 244 * <p>Returns {@code true} if rebuild has completed. 245 */ rebuildActiveTab(boolean doPostProcessing)246 boolean rebuildActiveTab(boolean doPostProcessing) { 247 Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); 248 boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); 249 Trace.endSection(); 250 return result; 251 } 252 253 /** 254 * Rebuilds the tab that is not currently visible to the user, if such one exists. 255 * <p>Returns {@code true} if rebuild has completed. 256 */ rebuildInactiveTab(boolean doPostProcessing)257 boolean rebuildInactiveTab(boolean doPostProcessing) { 258 Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); 259 if (getItemCount() == 1) { 260 Trace.endSection(); 261 return false; 262 } 263 boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); 264 Trace.endSection(); 265 return result; 266 } 267 userHandleToPageIndex(UserHandle userHandle)268 private int userHandleToPageIndex(UserHandle userHandle) { 269 if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { 270 return PROFILE_PERSONAL; 271 } else { 272 return PROFILE_WORK; 273 } 274 } 275 rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing)276 private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { 277 if (shouldSkipRebuild(activeListAdapter)) { 278 activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); 279 return false; 280 } 281 return activeListAdapter.rebuildList(doPostProcessing); 282 } 283 shouldSkipRebuild(ResolverListAdapter activeListAdapter)284 private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { 285 EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); 286 return emptyState != null && emptyState.shouldSkipDataRebuild(); 287 } 288 289 /** 290 * The empty state screens are shown according to their priority: 291 * <ol> 292 * <li>(highest priority) cross-profile disabled by policy (handled in 293 * {@link #rebuildTab(ResolverListAdapter, boolean)})</li> 294 * <li>no apps available</li> 295 * <li>(least priority) work is off</li> 296 * </ol> 297 * 298 * The intention is to prevent the user from having to turn 299 * the work profile on if there will not be any apps resolved 300 * anyway. 301 */ showEmptyResolverListEmptyState(ResolverListAdapter listAdapter)302 void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { 303 final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); 304 305 if (emptyState == null) { 306 return; 307 } 308 309 emptyState.onEmptyStateShown(); 310 311 View.OnClickListener clickListener = null; 312 313 if (emptyState.getButtonClickListener() != null) { 314 clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { 315 ProfileDescriptor descriptor = getItem( 316 userHandleToPageIndex(listAdapter.getUserHandle())); 317 AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); 318 }); 319 } 320 321 showEmptyState(listAdapter, emptyState, clickListener); 322 } 323 324 /** 325 * Class to get user id of the current process 326 */ 327 public static class MyUserIdProvider { 328 /** 329 * @return user id of the current process 330 */ getMyUserId()331 public int getMyUserId() { 332 return UserHandle.myUserId(); 333 } 334 } 335 336 /** 337 * Utility class to check if there are cross profile intents, it is in a separate class so 338 * it could be mocked in tests 339 */ 340 public static class CrossProfileIntentsChecker { 341 342 private final ContentResolver mContentResolver; 343 CrossProfileIntentsChecker(@onNull ContentResolver contentResolver)344 public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { 345 mContentResolver = contentResolver; 346 } 347 348 /** 349 * Returns {@code true} if at least one of the provided {@code intents} can be forwarded 350 * from {@code source} (user id) to {@code target} (user id). 351 */ hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, @UserIdInt int target)352 public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, 353 @UserIdInt int target) { 354 IPackageManager packageManager = AppGlobals.getPackageManager(); 355 356 return intents.stream().anyMatch(intent -> 357 null != IntentForwarderActivity.canForward(intent, source, target, 358 packageManager, mContentResolver)); 359 } 360 } 361 showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick)362 protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, 363 View.OnClickListener buttonOnClick) { 364 ProfileDescriptor descriptor = getItem( 365 userHandleToPageIndex(activeListAdapter.getUserHandle())); 366 descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); 367 ViewGroup emptyStateView = descriptor.getEmptyStateView(); 368 resetViewVisibilitiesForEmptyState(emptyStateView); 369 emptyStateView.setVisibility(View.VISIBLE); 370 371 View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); 372 setupContainerPadding(container); 373 374 TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); 375 String title = emptyState.getTitle(); 376 if (title != null) { 377 titleView.setVisibility(View.VISIBLE); 378 titleView.setText(title); 379 } else { 380 titleView.setVisibility(View.GONE); 381 } 382 383 TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); 384 String subtitle = emptyState.getSubtitle(); 385 if (subtitle != null) { 386 subtitleView.setVisibility(View.VISIBLE); 387 subtitleView.setText(subtitle); 388 } else { 389 subtitleView.setVisibility(View.GONE); 390 } 391 392 View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); 393 defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); 394 395 Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); 396 button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); 397 button.setOnClickListener(buttonOnClick); 398 399 activeListAdapter.markTabLoaded(); 400 } 401 402 /** 403 * Sets up the padding of the view containing the empty state screens. 404 * <p>This method is meant to be overridden so that subclasses can customize the padding. 405 */ setupContainerPadding(View container)406 protected void setupContainerPadding(View container) {} 407 showSpinner(View emptyStateView)408 private void showSpinner(View emptyStateView) { 409 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); 410 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); 411 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE); 412 emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); 413 } 414 resetViewVisibilitiesForEmptyState(View emptyStateView)415 private void resetViewVisibilitiesForEmptyState(View emptyStateView) { 416 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); 417 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); 418 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); 419 emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); 420 emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); 421 } 422 showListView(ResolverListAdapter activeListAdapter)423 protected void showListView(ResolverListAdapter activeListAdapter) { 424 ProfileDescriptor descriptor = getItem( 425 userHandleToPageIndex(activeListAdapter.getUserHandle())); 426 descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); 427 View emptyStateView = descriptor.rootView.findViewById(com.android.internal.R.id.resolver_empty_state); 428 emptyStateView.setVisibility(View.GONE); 429 } 430 shouldShowEmptyStateScreen(ResolverListAdapter listAdapter)431 boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { 432 int count = listAdapter.getUnfilteredCount(); 433 return (count == 0 && listAdapter.getPlaceholderCount() == 0) 434 || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) 435 && mWorkProfileQuietModeChecker.get()); 436 } 437 438 protected static class ProfileDescriptor { 439 final ViewGroup rootView; 440 private final ViewGroup mEmptyStateView; ProfileDescriptor(ViewGroup rootView)441 ProfileDescriptor(ViewGroup rootView) { 442 this.rootView = rootView; 443 mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); 444 } 445 getEmptyStateView()446 protected ViewGroup getEmptyStateView() { 447 return mEmptyStateView; 448 } 449 } 450 451 public interface OnProfileSelectedListener { 452 /** 453 * Callback for when the user changes the active tab from personal to work or vice versa. 454 * <p>This callback is only called when the intent resolver or share sheet shows 455 * the work and personal profiles. 456 * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or 457 * {@link #PROFILE_WORK} if the work profile was selected. 458 */ onProfileSelected(int profileIndex)459 void onProfileSelected(int profileIndex); 460 461 462 /** 463 * Callback for when the scroll state changes. Useful for discovering when the user begins 464 * dragging, when the pager is automatically settling to the current page, or when it is 465 * fully stopped/idle. 466 * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} 467 * or {@link ViewPager#SCROLL_STATE_SETTLING} 468 * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged 469 */ onProfilePageStateChanged(int state)470 void onProfilePageStateChanged(int state); 471 } 472 473 /** 474 * Returns an empty state to show for the current profile page (tab) if necessary. 475 * This could be used e.g. to show a blocker on a tab if device management policy doesn't 476 * allow to use it or there are no apps available. 477 */ 478 public interface EmptyStateProvider { 479 /** 480 * When a non-null empty state is returned the corresponding profile page will show 481 * this empty state 482 * @param resolverListAdapter the current adapter 483 */ 484 @Nullable getEmptyState(ResolverListAdapter resolverListAdapter)485 default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { 486 return null; 487 } 488 } 489 490 /** 491 * Empty state provider that combines multiple providers. Providers earlier in the list have 492 * priority, that is if there is a provider that returns non-null empty state then all further 493 * providers will be ignored. 494 */ 495 public static class CompositeEmptyStateProvider implements EmptyStateProvider { 496 497 private final EmptyStateProvider[] mProviders; 498 CompositeEmptyStateProvider(EmptyStateProvider... providers)499 public CompositeEmptyStateProvider(EmptyStateProvider... providers) { 500 mProviders = providers; 501 } 502 503 @Nullable 504 @Override getEmptyState(ResolverListAdapter resolverListAdapter)505 public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { 506 for (EmptyStateProvider provider : mProviders) { 507 EmptyState emptyState = provider.getEmptyState(resolverListAdapter); 508 if (emptyState != null) { 509 return emptyState; 510 } 511 } 512 return null; 513 } 514 } 515 516 /** 517 * Describes how the blocked empty state should look like for a profile tab 518 */ 519 public interface EmptyState { 520 /** 521 * Title that will be shown on the empty state 522 */ 523 @Nullable getTitle()524 default String getTitle() { return null; } 525 526 /** 527 * Subtitle that will be shown underneath the title on the empty state 528 */ 529 @Nullable getSubtitle()530 default String getSubtitle() { return null; } 531 532 /** 533 * If non-null then a button will be shown and this listener will be called 534 * when the button is clicked 535 */ 536 @Nullable getButtonClickListener()537 default ClickListener getButtonClickListener() { return null; } 538 539 /** 540 * If true then default text ('No apps can perform this action') and style for the empty 541 * state will be applied, title and subtitle will be ignored. 542 */ useDefaultEmptyView()543 default boolean useDefaultEmptyView() { return false; } 544 545 /** 546 * Returns true if for this empty state we should skip rebuilding of the apps list 547 * for this tab. 548 */ shouldSkipDataRebuild()549 default boolean shouldSkipDataRebuild() { return false; } 550 551 /** 552 * Called when empty state is shown, could be used e.g. to track analytics events 553 */ onEmptyStateShown()554 default void onEmptyStateShown() {} 555 556 interface ClickListener { onClick(TabControl currentTab)557 void onClick(TabControl currentTab); 558 } 559 560 interface TabControl { showSpinner()561 void showSpinner(); 562 } 563 } 564 565 566 /** 567 * Listener for when the user switches on the work profile from the work tab. 568 */ 569 interface OnSwitchOnWorkSelectedListener { 570 /** 571 * Callback for when the user switches on the work profile from the work tab. 572 */ onSwitchOnWorkSelected()573 void onSwitchOnWorkSelected(); 574 } 575 } 576