/* * Copyright (C) 2019 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.internal.app; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UserIdInt; import android.app.AppGlobals; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.pm.IPackageManager; import android.os.Trace; import android.os.UserHandle; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; import com.android.internal.R; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.widget.PagerAdapter; import com.android.internal.widget.ViewPager; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; /** * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for * intent resolution (including share sheet). */ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { private static final String TAG = "AbstractMultiProfilePagerAdapter"; static final int PROFILE_PERSONAL = 0; static final int PROFILE_WORK = 1; @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) @interface Profile {} private final Context mContext; private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; private Set mLoadedPages; private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; private final UserHandle mCloneUserHandle; private final QuietModeManager mQuietModeManager; AbstractMultiProfilePagerAdapter(Context context, int currentPage, EmptyStateProvider emptyStateProvider, QuietModeManager quietModeManager, UserHandle workProfileUserHandle, UserHandle cloneUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mCloneUserHandle = cloneUserHandle; mEmptyStateProvider = emptyStateProvider; mQuietModeManager = quietModeManager; } private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle); } void setOnProfileSelectedListener(OnProfileSelectedListener listener) { mOnProfileSelectedListener = listener; } Context getContext() { return mContext; } /** * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed * page and rebuilds the list. */ void setupViewPager(ViewPager viewPager) { viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { mCurrentPage = position; if (!mLoadedPages.contains(position)) { rebuildActiveTab(true); mLoadedPages.add(position); } if (mOnProfileSelectedListener != null) { mOnProfileSelectedListener.onProfileSelected(position); } } @Override public void onPageScrollStateChanged(int state) { if (mOnProfileSelectedListener != null) { mOnProfileSelectedListener.onProfilePageStateChanged(state); } } }); viewPager.setAdapter(this); viewPager.setCurrentItem(mCurrentPage); mLoadedPages.add(mCurrentPage); } void clearInactiveProfileCache() { if (mLoadedPages.size() == 1) { return; } mLoadedPages.remove(1 - mCurrentPage); } @Override public ViewGroup instantiateItem(ViewGroup container, int position) { final ProfileDescriptor profileDescriptor = getItem(position); container.addView(profileDescriptor.rootView); return profileDescriptor.rootView; } @Override public void destroyItem(ViewGroup container, int position, Object view) { container.removeView((View) view); } @Override public int getCount() { return getItemCount(); } protected int getCurrentPage() { return mCurrentPage; } @VisibleForTesting public UserHandle getCurrentUserHandle() { return getActiveListAdapter().mResolverListController.getUserHandle(); } @Override public boolean isViewFromObject(View view, Object object) { return view == object; } @Override public CharSequence getPageTitle(int position) { return null; } public UserHandle getCloneUserHandle() { return mCloneUserHandle; } /** * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. * */ public abstract ProfileDescriptor getItem(int pageIndex); /** * Returns the number of {@link ProfileDescriptor} objects. *

For a normal consumer device with only one user returns 1. *

For a device with a work profile returns 2. */ abstract int getItemCount(); /** * Performs view-related initialization procedures for the adapter specified * by pageIndex. */ abstract void setupListAdapter(int pageIndex); /** * Returns the adapter of the list view for the relevant page specified by * pageIndex. *

This method is meant to be implemented with an implementation-specific return type * depending on the adapter type. */ @VisibleForTesting public abstract Object getAdapterForIndex(int pageIndex); /** * Returns the {@link ResolverListAdapter} instance of the profile that represents * userHandle. If there is no such adapter for the specified * userHandle, returns {@code null}. *

For example, if there is a work profile on the device with user id 10, calling this method * with UserHandle.of(10) returns the work profile {@link ResolverListAdapter}. */ @Nullable abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle); /** * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible * to the user. *

For example, if the user is viewing the work tab in the share sheet, this method returns * the work profile {@link ResolverListAdapter}. * @see #getInactiveListAdapter() */ @VisibleForTesting public abstract ResolverListAdapter getActiveListAdapter(); /** * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance * of the profile that is not currently visible to the user. Otherwise returns * {@code null}. *

For example, if the user is viewing the work tab in the share sheet, this method returns * the personal profile {@link ResolverListAdapter}. * @see #getActiveListAdapter() */ @VisibleForTesting public abstract @Nullable ResolverListAdapter getInactiveListAdapter(); public abstract ResolverListAdapter getPersonalListAdapter(); public abstract @Nullable ResolverListAdapter getWorkListAdapter(); abstract Object getCurrentRootAdapter(); abstract ViewGroup getActiveAdapterView(); abstract @Nullable ViewGroup getInactiveAdapterView(); /** * Rebuilds the tab that is currently visible to the user. *

Returns {@code true} if rebuild has completed. */ boolean rebuildActiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); Trace.endSection(); return result; } /** * Rebuilds the tab that is not currently visible to the user, if such one exists. *

Returns {@code true} if rebuild has completed. */ boolean rebuildInactiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); if (getItemCount() == 1) { Trace.endSection(); return false; } boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); Trace.endSection(); return result; } private int userHandleToPageIndex(UserHandle userHandle) { if (userHandle.equals(getPersonalListAdapter().mResolverListController.getUserHandle())) { return PROFILE_PERSONAL; } else { return PROFILE_WORK; } } private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); return false; } return activeListAdapter.rebuildList(doPostProcessing); } private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); return emptyState != null && emptyState.shouldSkipDataRebuild(); } /** * The empty state screens are shown according to their priority: *

    *
  1. (highest priority) cross-profile disabled by policy (handled in * {@link #rebuildTab(ResolverListAdapter, boolean)})
  2. *
  3. no apps available
  4. *
  5. (least priority) work is off
  6. *
* * The intention is to prevent the user from having to turn * the work profile on if there will not be any apps resolved * anyway. */ void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); if (emptyState == null) { return; } emptyState.onEmptyStateShown(); View.OnClickListener clickListener = null; if (emptyState.getButtonClickListener() != null) { clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(listAdapter.getUserHandle())); AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); }); } showEmptyState(listAdapter, emptyState, clickListener); } /** * Class to get user id of the current process */ public static class MyUserIdProvider { /** * @return user id of the current process */ public int getMyUserId() { return UserHandle.myUserId(); } } /** * Utility class to check if there are cross profile intents, it is in a separate class so * it could be mocked in tests */ public static class CrossProfileIntentsChecker { private final ContentResolver mContentResolver; public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { mContentResolver = contentResolver; } /** * Returns {@code true} if at least one of the provided {@code intents} can be forwarded * from {@code source} (user id) to {@code target} (user id). */ public boolean hasCrossProfileIntents(List intents, @UserIdInt int source, @UserIdInt int target) { IPackageManager packageManager = AppGlobals.getPackageManager(); return intents.stream().anyMatch(intent -> null != IntentForwarderActivity.canForward(intent, source, target, packageManager, mContentResolver)); } } protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.rootView.findViewById(R.id.resolver_list).setVisibility(View.GONE); ViewGroup emptyStateView = descriptor.getEmptyStateView(); resetViewVisibilitiesForEmptyState(emptyStateView); emptyStateView.setVisibility(View.VISIBLE); View container = emptyStateView.findViewById(R.id.resolver_empty_state_container); setupContainerPadding(container); TextView titleView = emptyStateView.findViewById(R.id.resolver_empty_state_title); String title = emptyState.getTitle(); if (title != null) { titleView.setVisibility(View.VISIBLE); titleView.setText(title); } else { titleView.setVisibility(View.GONE); } TextView subtitleView = emptyStateView.findViewById(R.id.resolver_empty_state_subtitle); String subtitle = emptyState.getSubtitle(); if (subtitle != null) { subtitleView.setVisibility(View.VISIBLE); subtitleView.setText(subtitle); } else { subtitleView.setVisibility(View.GONE); } View defaultEmptyText = emptyStateView.findViewById(R.id.empty); defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); Button button = emptyStateView.findViewById(R.id.resolver_empty_state_button); button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); button.setOnClickListener(buttonOnClick); activeListAdapter.markTabLoaded(); } /** * Sets up the padding of the view containing the empty state screens. *

This method is meant to be overridden so that subclasses can customize the padding. */ protected void setupContainerPadding(View container) {} private void showSpinner(View emptyStateView) { emptyStateView.findViewById(R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); emptyStateView.findViewById(R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); emptyStateView.findViewById(R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE); emptyStateView.findViewById(R.id.empty).setVisibility(View.GONE); } private void resetViewVisibilitiesForEmptyState(View emptyStateView) { emptyStateView.findViewById(R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); emptyStateView.findViewById(R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); emptyStateView.findViewById(R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); emptyStateView.findViewById(R.id.resolver_empty_state_progress).setVisibility(View.GONE); emptyStateView.findViewById(R.id.empty).setVisibility(View.GONE); } protected void showListView(ResolverListAdapter activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.rootView.findViewById(R.id.resolver_list).setVisibility(View.VISIBLE); View emptyStateView = descriptor.rootView.findViewById(R.id.resolver_empty_state); emptyStateView.setVisibility(View.GONE); } boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) && isQuietModeEnabled(mWorkProfileUserHandle)); } public static class ProfileDescriptor { public final ViewGroup rootView; private final ViewGroup mEmptyStateView; ProfileDescriptor(ViewGroup rootView) { this.rootView = rootView; mEmptyStateView = rootView.findViewById(R.id.resolver_empty_state); } protected ViewGroup getEmptyStateView() { return mEmptyStateView; } } public interface OnProfileSelectedListener { /** * Callback for when the user changes the active tab from personal to work or vice versa. *

This callback is only called when the intent resolver or share sheet shows * the work and personal profiles. * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or * {@link #PROFILE_WORK} if the work profile was selected. */ void onProfileSelected(int profileIndex); /** * Callback for when the scroll state changes. Useful for discovering when the user begins * dragging, when the pager is automatically settling to the current page, or when it is * fully stopped/idle. * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} * or {@link ViewPager#SCROLL_STATE_SETTLING} * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged */ void onProfilePageStateChanged(int state); } /** * Returns an empty state to show for the current profile page (tab) if necessary. * This could be used e.g. to show a blocker on a tab if device management policy doesn't * allow to use it or there are no apps available. */ public interface EmptyStateProvider { /** * When a non-null empty state is returned the corresponding profile page will show * this empty state * @param resolverListAdapter the current adapter */ @Nullable default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { return null; } } /** * Empty state provider that combines multiple providers. Providers earlier in the list have * priority, that is if there is a provider that returns non-null empty state then all further * providers will be ignored. */ public static class CompositeEmptyStateProvider implements EmptyStateProvider { private final EmptyStateProvider[] mProviders; public CompositeEmptyStateProvider(EmptyStateProvider... providers) { mProviders = providers; } @Nullable @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { for (EmptyStateProvider provider : mProviders) { EmptyState emptyState = provider.getEmptyState(resolverListAdapter); if (emptyState != null) { return emptyState; } } return null; } } /** * Describes how the blocked empty state should look like for a profile tab */ public interface EmptyState { /** * Title that will be shown on the empty state */ @Nullable default String getTitle() { return null; } /** * Subtitle that will be shown underneath the title on the empty state */ @Nullable default String getSubtitle() { return null; } /** * If non-null then a button will be shown and this listener will be called * when the button is clicked */ @Nullable default ClickListener getButtonClickListener() { return null; } /** * If true then default text ('No apps can perform this action') and style for the empty * state will be applied, title and subtitle will be ignored. */ default boolean useDefaultEmptyView() { return false; } /** * Returns true if for this empty state we should skip rebuilding of the apps list * for this tab. */ default boolean shouldSkipDataRebuild() { return false; } /** * Called when empty state is shown, could be used e.g. to track analytics events */ default void onEmptyStateShown() {} interface ClickListener { void onClick(TabControl currentTab); } interface TabControl { void showSpinner(); } } /** * Listener for when the user switches on the work profile from the work tab. */ interface OnSwitchOnWorkSelectedListener { /** * Callback for when the user switches on the work profile from the work tab. */ void onSwitchOnWorkSelected(); } /** * Describes an injector to be used for cross profile functionality. Overridable for testing. */ public interface QuietModeManager { /** * Returns whether the given profile is in quiet mode or not. */ boolean isQuietModeEnabled(UserHandle workProfileUserHandle); /** * Enables or disables quiet mode for a managed profile. */ void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle); /** * Should be called when the work profile enabled broadcast received */ void markWorkProfileEnabledBroadcastReceived(); /** * Returns true if enabling of work profile is in progress */ boolean isWaitingToEnableWorkProfile(); } }