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