From 64eea1da84ee8f7883d3b307e50fcd24f1bc30d9 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 26 Sep 2022 20:34:59 +0000 Subject: Multi-image editor fix Same as ag/20056428, but unbundled. Bug: 247638926 Test: Share multiple images from photos, verify that edit button doesn't appear. Change-Id: I0b135dae151abf307e30639c309c2d459938d867 --- java/src/com/android/intentresolver/ChooserActivity.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 14d77427..d4855382 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1108,6 +1108,19 @@ public class ChooserActivity extends ResolverActivity implements resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); resolveIntent.setComponent(cn); resolveIntent.setAction(Intent.ACTION_EDIT); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } final ResolveInfo ri = getPackageManager().resolveActivity( resolveIntent, PackageManager.GET_META_DATA); if (ri == null || ri.activityInfo == null) { -- cgit v1.2.3-59-g8ed1b From 8ce4d4290029b8f94a35ad4ed2459dd4d980b36b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 28 Sep 2022 14:37:37 -0700 Subject: Move shortcut processing logic into separate components. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As part of the ongoing architectural change, move shortcut processing logic from ChooserListAdapter and ChooserActivity into spaparte components with no functional changes; add unit tests. The new ShrtcutToChooserTargetConverter class now contains previous ChooserActivity’s convertToChooserTarget method; two related test cases were moved from UnboundedChooserActivityTest class into the new component’s test class. The new ShortcutSelectionLogic class now contains previous ChooserListAdapter’s addServiceResults and insertServiceTarget methods; new unit tests added. Test: atest IntentResolverUnitTests:ShortcutSelectionLogicTest Test: atest IntentResolverUnitTests:ShortcutToChooserTargetConverterTest Test: manual test Fix: 249166394 Change-Id: I49baf7d4a435b1f270554fdf6e004875117a01d8 --- .../android/intentresolver/ChooserActivity.java | 92 +------ .../android/intentresolver/ChooserListAdapter.java | 156 +++--------- .../intentresolver/ShortcutSelectionLogic.java | 166 +++++++++++++ .../ShortcutToChooserTargetConverter.java | 109 +++++++++ .../chooser/SelectableTargetInfo.java | 5 +- java/tests/Android.bp | 2 +- .../android/intentresolver/MockitoKotlinHelpers.kt | 146 +++++++++++ .../intentresolver/ShortcutSelectionLogicTest.kt | 271 +++++++++++++++++++++ .../ShortcutToChooserTargetConverterTest.kt | 175 +++++++++++++ .../src/com/android/intentresolver/TestHelpers.kt | 71 ++++++ .../UnbundledChooserActivityTest.java | 101 -------- 11 files changed, 988 insertions(+), 306 deletions(-) create mode 100644 java/src/com/android/intentresolver/ShortcutSelectionLogic.java create mode 100644 java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java create mode 100644 java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt create mode 100644 java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt create mode 100644 java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestHelpers.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 14d77427..36f7ad2a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -26,7 +26,6 @@ import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.annotation.IntDef; -import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; @@ -121,7 +120,6 @@ import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; - import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; @@ -142,7 +140,6 @@ import java.lang.annotation.RetentionPolicy; import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -321,6 +318,9 @@ public class ChooserActivity extends ResolverActivity implements private View mContentView = null; + private ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = + new ShortcutToChooserTargetConverter(); + private class ContentPreviewCoordinator { private static final int IMAGE_FADE_IN_MILLIS = 150; private static final int IMAGE_LOAD_TIMEOUT = 1; @@ -2054,8 +2054,13 @@ public class ChooserActivity extends ResolverActivity implements if (matchingShortcuts.isEmpty()) { continue; } - List chooserTargets = convertToChooserTarget( - matchingShortcuts, resultList, appTargets, shortcutType); + List chooserTargets = mShortcutToChooserTargetConverter + .convertToChooserTarget( + matchingShortcuts, + resultList, + appTargets, + mDirectShareAppTargetCache, + mDirectShareShortcutInfoCache); ServiceResultInfo resultRecord = new ServiceResultInfo( displayResolveInfo, chooserTargets, userHandle); @@ -2104,75 +2109,6 @@ public class ChooserActivity extends ResolverActivity implements return false; } - /** - * Converts a list of ShareShortcutInfos to ChooserTargets. - * @param matchingShortcuts List of shortcuts, all from the same package, that match the current - * share intent filter. - * @param allShortcuts List of all the shortcuts from all the packages on the device that are - * returned for the current sharing action. - * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. - * @param shortcutType One of the values TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER or - * TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - * @return A list of ChooserTargets sorted by score in descending order. - */ - @VisibleForTesting - @NonNull - public List convertToChooserTarget( - @NonNull List matchingShortcuts, - @NonNull List allShortcuts, - @Nullable List allAppTargets, @ShareTargetType int shortcutType) { - // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted - // list instead of the actual rank value when converting a rank to a score. - List scoreList = new ArrayList<>(); - if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER) { - for (int i = 0; i < matchingShortcuts.size(); i++) { - int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); - if (!scoreList.contains(shortcutRank)) { - scoreList.add(shortcutRank); - } - } - Collections.sort(scoreList); - } - - List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); - for (int i = 0; i < matchingShortcuts.size(); i++) { - ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); - int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); - - float score; - if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE) { - // Incoming results are ordered. Create a score based on index in the original list. - score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); - } else { - // Create a score based on the rank of the shortcut. - int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); - score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); - } - - Bundle extras = new Bundle(); - extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); - - ChooserTarget chooserTarget = new ChooserTarget( - shortcutInfo.getLabel(), - null, // Icon will be loaded later if this target is selected to be shown. - score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); - - chooserTargetList.add(chooserTarget); - if (mDirectShareAppTargetCache != null && allAppTargets != null) { - mDirectShareAppTargetCache.put(chooserTarget, - allAppTargets.get(indexInAllShortcuts)); - } - if (mDirectShareShortcutInfoCache != null) { - mDirectShareShortcutInfoCache.put(chooserTarget, shortcutInfo); - } - } - // Sort ChooserTargets by score in descending order - Comparator byScore = - (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); - Collections.sort(chooserTargetList, byScore); - return chooserTargetList; - } - private void logDirectShareTargetReceived(int logCategory) { final int apiLatency = (int) (System.currentTimeMillis() - mQueriedSharingShortcutsTimeMs); getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); @@ -2691,14 +2627,6 @@ public class ChooserActivity extends ResolverActivity implements return mChooserMultiProfilePagerAdapter.getItem(currentPage).getEmptyStateView(); } - static class BaseChooserTargetComparator implements Comparator { - @Override - public int compare(ChooserTarget lhs, ChooserTarget rhs) { - // Descending order - return (int) Math.signum(rhs.getScore() - lhs.getScore()); - } - } - @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 6ddaffd7..849fa43d 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -19,6 +19,7 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import android.annotation.Nullable; import android.app.ActivityManager; import android.app.prediction.AppPredictor; import android.content.ComponentName; @@ -48,7 +49,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; - import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -72,23 +72,18 @@ public class ChooserListAdapter extends ResolverListAdapter { public static final int TARGET_STANDARD_AZ = 3; private static final int MAX_SUGGESTED_APP_TARGETS = 4; - private static final int MAX_CHOOSER_TARGETS_PER_APP = 2; /** {@link #getBaseScore} */ public static final float CALLER_TARGET_SCORE_BOOST = 900.f; /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; - private final int mMaxShortcutTargetsPerApp; private final ChooserListCommunicator mChooserListCommunicator; private final SelectableTargetInfo.SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; private final ChooserActivityLogger mChooserActivityLogger; - private int mNumShortcutResults = 0; private final Map mIconLoaders = new HashMap<>(); - private boolean mApplySharingAppLimits; // Reserve spots for incoming direct share targets by adding placeholders private ChooserTargetInfo @@ -96,8 +91,6 @@ public class ChooserListAdapter extends ResolverListAdapter { private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); - private final ChooserActivity.BaseChooserTargetComparator mBaseTargetComparator = - new ChooserActivity.BaseChooserTargetComparator(); private boolean mListViewDataChanged = false; // Sorted list of DisplayResolveInfos for the alphabetical app section. @@ -107,6 +100,8 @@ public class ChooserListAdapter extends ResolverListAdapter { private LoadDirectShareIconTaskProvider mTestLoadDirectShareTaskProvider; + private final ShortcutSelectionLogic mShortcutSelectionLogic; + // For pinned direct share labels, if the text spans multiple lines, the TextView will consume // the full width, even if the characters actually take up less than that. Measure the actual // line widths and constrain the View's width based upon that so that the pin doesn't end up @@ -138,9 +133,13 @@ public class ChooserListAdapter extends ResolverListAdapter { } }; - public ChooserListAdapter(Context context, List payloadIntents, - Intent[] initialIntents, List rList, - boolean filterLastUsed, ResolverListController resolverListController, + public ChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, ChooserListCommunicator chooserListCommunicator, SelectableTargetInfo.SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, PackageManager packageManager, @@ -150,12 +149,17 @@ public class ChooserListAdapter extends ResolverListAdapter { super(context, payloadIntents, null, rList, filterLastUsed, resolverListController, chooserListCommunicator, false); - mMaxShortcutTargetsPerApp = - context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp); mChooserListCommunicator = chooserListCommunicator; createPlaceHolders(); mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator; mChooserActivityLogger = chooserActivityLogger; + mShortcutSelectionLogic = new ShortcutSelectionLogic( + context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp), + DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true) + ); if (initialIntents != null) { for (int i = 0; i < initialIntents.length; i++) { @@ -208,10 +212,6 @@ public class ChooserListAdapter extends ResolverListAdapter { if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } } - mApplySharingAppLimits = DeviceConfig.getBoolean( - DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - true); } AppPredictor getAppPredictor() { @@ -244,7 +244,6 @@ public class ChooserListAdapter extends ResolverListAdapter { } private void createPlaceHolders() { - mNumShortcutResults = 0; mServiceTargets.clear(); for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { mServiceTargets.add(mPlaceHolderTargetInfo); @@ -556,82 +555,34 @@ public class ChooserListAdapter extends ResolverListAdapter { * Evaluate targets for inclusion in the direct share area. May not be included * if score is too low. */ - public void addServiceResults(DisplayResolveInfo origTarget, List targets, + public void addServiceResults( + @Nullable DisplayResolveInfo origTarget, + List targets, @ChooserActivity.ShareTargetType int targetType, Map directShareToShortcutInfos) { - if (DEBUG) { - Log.d(TAG, "addServiceResults " + origTarget.getResolvedComponentName() + ", " - + targets.size() - + " targets"); - } - if (targets.size() == 0) { + // Avoid inserting any potentially late results + if (mServiceTargets.size() == 1 + && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) { return; } - final float baseScore = getBaseScore(origTarget, targetType); - Collections.sort(targets, mBaseTargetComparator); - final boolean isShortcutResult = - (targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER - || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp - : MAX_CHOOSER_TARGETS_PER_APP; - final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets) - : targets.size(); - float lastScore = 0; - boolean shouldNotify = false; - for (int i = 0, count = targetsLimit; i < count; i++) { - final ChooserTarget target = targets.get(i); - float targetScore = target.getScore(); - if (mApplySharingAppLimits) { - targetScore *= baseScore; - if (i > 0 && targetScore >= lastScore) { - // Apply a decay so that the top app can't crowd out everything else. - // This incents ChooserTargetServices to define what's truly better. - targetScore = lastScore * 0.95f; - } - } - ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target) - : null; - if ((shortcutInfo != null) && shortcutInfo.isPinned()) { - targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; - } - UserHandle userHandle = getUserHandle(); - Context contextAsUser = mContext.createContextAsUser(userHandle, 0 /* flags */); - boolean isInserted = insertServiceTarget(new SelectableTargetInfo(contextAsUser, - origTarget, target, targetScore, mSelectableTargetInfoCommunicator, - shortcutInfo)); - - if (isInserted && isShortcutResult) { - mNumShortcutResults++; - } - - shouldNotify |= isInserted; - - if (DEBUG) { - Log.d(TAG, " => " + target.toString() + " score=" + targetScore - + " base=" + target.getScore() - + " lastScore=" + lastScore - + " baseScore=" + baseScore - + " applyAppLimit=" + mApplySharingAppLimits); - } - - lastScore = targetScore; - } - if (shouldNotify) { + boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER + || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; + boolean isUpdated = mShortcutSelectionLogic.addServiceResults( + origTarget, + getBaseScore(origTarget, targetType), + targets, + isShortcutResult, + directShareToShortcutInfos, + mContext.createContextAsUser(getUserHandle(), 0), + mSelectableTargetInfoCommunicator, + mChooserListCommunicator.getMaxRankedTargets(), + mServiceTargets); + if (isUpdated) { notifyDataSetChanged(); } } - /** - * The return number have to exceed a minimum limit to make direct share area expandable. When - * append direct share targets is enabled, return count of all available targets parking in the - * memory; otherwise, it is shortcuts count which will help reduce the amount of visible - * shuffling due to older-style direct share targets. - */ - int getNumServiceTargetsForExpand() { - return mNumShortcutResults; - } - /** * Use the scoring system along with artificial boosts to create up to 4 distinct buckets: *
    @@ -668,41 +619,6 @@ public class ChooserListAdapter extends ResolverListAdapter { notifyDataSetChanged(); } - private boolean insertServiceTarget(ChooserTargetInfo chooserTargetInfo) { - // Avoid inserting any potentially late results - if (mServiceTargets.size() == 1 - && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) { - return false; - } - - // Check for duplicates and abort if found - for (ChooserTargetInfo otherTargetInfo : mServiceTargets) { - if (chooserTargetInfo.isSimilar(otherTargetInfo)) { - return false; - } - } - - int currentSize = mServiceTargets.size(); - final float newScore = chooserTargetInfo.getModifiedScore(); - for (int i = 0; i < Math.min(currentSize, mChooserListCommunicator.getMaxRankedTargets()); - i++) { - final ChooserTargetInfo serviceTarget = mServiceTargets.get(i); - if (serviceTarget == null) { - mServiceTargets.set(i, chooserTargetInfo); - return true; - } else if (newScore > serviceTarget.getModifiedScore()) { - mServiceTargets.add(i, chooserTargetInfo); - return true; - } - } - - if (currentSize < mChooserListCommunicator.getMaxRankedTargets()) { - mServiceTargets.add(chooserTargetInfo); - return true; - } - - return false; - } public ChooserTarget getChooserTargetForValue(int value) { return mServiceTargets.get(value).getChooserTarget(); diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java new file mode 100644 index 00000000..e9470231 --- /dev/null +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ShortcutInfo; +import android.service.chooser.ChooserTarget; +import android.util.Log; + +import com.android.intentresolver.chooser.ChooserTargetInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutSelectionLogic { + private static final String TAG = "ShortcutSelectionLogic"; + private static final boolean DEBUG = false; + private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; + private static final int MAX_CHOOSER_TARGETS_PER_APP = 2; + + private final int mMaxShortcutTargetsPerApp; + private final boolean mApplySharingAppLimits; + + // Descending order + private final Comparator mBaseTargetComparator = + (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore()); + + ShortcutSelectionLogic( + int maxShortcutTargetsPerApp, + boolean applySharingAppLimits) { + mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp; + mApplySharingAppLimits = applySharingAppLimits; + } + + /** + * Evaluate targets for inclusion in the direct share area. May not be included + * if score is too low. + */ + public boolean addServiceResults( + @Nullable DisplayResolveInfo origTarget, + float origTargetScore, + List targets, + boolean isShortcutResult, + Map directShareToShortcutInfos, + Context userContext, + SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator, + int maxRankedTargets, + List serviceTargets) { + if (DEBUG) { + Log.d(TAG, "addServiceResults " + + (origTarget == null ? null : origTarget.getResolvedComponentName()) + ", " + + targets.size() + + " targets"); + } + if (targets.size() == 0) { + return false; + } + Collections.sort(targets, mBaseTargetComparator); + final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp + : MAX_CHOOSER_TARGETS_PER_APP; + final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets) + : targets.size(); + float lastScore = 0; + boolean shouldNotify = false; + for (int i = 0, count = targetsLimit; i < count; i++) { + final ChooserTarget target = targets.get(i); + float targetScore = target.getScore(); + if (mApplySharingAppLimits) { + targetScore *= origTargetScore; + if (i > 0 && targetScore >= lastScore) { + // Apply a decay so that the top app can't crowd out everything else. + // This incents ChooserTargetServices to define what's truly better. + targetScore = lastScore * 0.95f; + } + } + ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target) + : null; + if ((shortcutInfo != null) && shortcutInfo.isPinned()) { + targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; + } + boolean isInserted = insertServiceTarget( + new SelectableTargetInfo( + userContext, + origTarget, + target, + targetScore, + mSelectableTargetInfoCommunicator, + shortcutInfo), + maxRankedTargets, + serviceTargets); + + shouldNotify |= isInserted; + + if (DEBUG) { + Log.d(TAG, " => " + target + " score=" + targetScore + + " base=" + target.getScore() + + " lastScore=" + lastScore + + " baseScore=" + origTargetScore + + " applyAppLimit=" + mApplySharingAppLimits); + } + + lastScore = targetScore; + } + + return shouldNotify; + } + + private boolean insertServiceTarget( + SelectableTargetInfo chooserTargetInfo, + int maxRankedTargets, + List serviceTargets) { + + // Check for duplicates and abort if found + for (ChooserTargetInfo otherTargetInfo : serviceTargets) { + if (chooserTargetInfo.isSimilar(otherTargetInfo)) { + return false; + } + } + + int currentSize = serviceTargets.size(); + final float newScore = chooserTargetInfo.getModifiedScore(); + for (int i = 0; i < Math.min(currentSize, maxRankedTargets); + i++) { + final ChooserTargetInfo serviceTarget = serviceTargets.get(i); + if (serviceTarget == null) { + serviceTargets.set(i, chooserTargetInfo); + return true; + } else if (newScore > serviceTarget.getModifiedScore()) { + serviceTargets.add(i, chooserTargetInfo); + return true; + } + } + + if (currentSize < maxRankedTargets) { + serviceTargets.add(chooserTargetInfo); + return true; + } + + return false; + } + + public interface ScoreProvider { + float getScore(ComponentName componentName); + } +} diff --git a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java new file mode 100644 index 00000000..ac4270d3 --- /dev/null +++ b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.prediction.AppTarget; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutToChooserTargetConverter { + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget + * instances back to original allAppTargets. + * @param directShareShortcutInfoCache An optional map to store mapping from the new + * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} + * @return A list of ChooserTargets sorted by score in descending order. + */ + @NonNull + public List convertToChooserTarget( + @NonNull List matchingShortcuts, + @NonNull List allShortcuts, + @Nullable List allAppTargets, + @Nullable Map directShareAppTargetCache, + @Nullable Map directShareShortcutInfoCache) { + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final boolean isFromAppPredictor = allAppTargets != null; + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List scoreList = new ArrayList<>(); + if (!isFromAppPredictor) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (isFromAppPredictor) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (directShareAppTargetCache != null && allAppTargets != null) { + directShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (directShareShortcutInfoCache != null) { + directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } +} diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 1610d0fd..dffe2f6c 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -41,7 +41,6 @@ import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; import com.android.intentresolver.SimpleIconFactory; - import com.android.internal.annotations.GuardedBy; import java.util.ArrayList; @@ -55,6 +54,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { private static final String TAG = "SelectableTargetInfo"; private final Context mContext; + @Nullable private final DisplayResolveInfo mSourceInfo; private final ResolveInfo mBackupResolveInfo; private final ChooserTarget mChooserTarget; @@ -73,7 +73,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { private final float mModifiedScore; private boolean mIsSuspended = false; - public SelectableTargetInfo(Context context, DisplayResolveInfo sourceInfo, + public SelectableTargetInfo(Context context, @Nullable DisplayResolveInfo sourceInfo, ChooserTarget chooserTarget, float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator, @Nullable ShortcutInfo shortcutInfo) { @@ -144,6 +144,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { return mIsSuspended; } + @Nullable public DisplayResolveInfo getDisplayResolveInfo() { return mSourceInfo; } diff --git a/java/tests/Android.bp b/java/tests/Android.bp index fdabc4e0..92974aba 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -7,7 +7,7 @@ android_test { name: "IntentResolverUnitTests", // Include all test java files. - srcs: ["src/**/*.java"], + srcs: ["src/**/*.java", "src/**/*.kt"], libs: [ "android.test.runner", diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt new file mode 100644 index 00000000..159c6d6a --- /dev/null +++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2022 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.intentresolver + +/** + * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects + * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not + * be null"). To fix this, we can use methods that modify the return type to be nullable. This + * causes Kotlin to skip the null checks. + * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt + */ + +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatcher +import org.mockito.Mockito +import org.mockito.stubbing.OngoingStubbing + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun eq(obj: T): T = Mockito.eq(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun any(type: Class): T = Mockito.any(type) +inline fun any(): T = any(T::class.java) + +/** + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun argThat(matcher: ArgumentMatcher): T = Mockito.argThat(matcher) + +/** + * Kotlin type-inferred version of Mockito.nullable() + */ +inline fun nullable(): T? = Mockito.nullable(T::class.java) + +/** + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException + * when null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun argumentCaptor(): ArgumentCaptor = + ArgumentCaptor.forClass(T::class.java) + +/** + * Helper function for creating new mocks, without the need to pass in a [Class] instance. + * + * Generic T is nullable because implicitly bounded by Any?. + * + * @param apply builder function to simplify stub configuration by improving type inference. + */ +inline fun mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java) + .apply(apply) + +/** + * Helper function for stubbing methods without the need to use backticks. + * + * @see Mockito.when + */ +fun whenever(methodCall: T): OngoingStubbing = Mockito.`when`(methodCall) + +/** + * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when + * kotlin tests are mocking kotlin objects and the methods take non-null parameters: + * + * java.lang.NullPointerException: capture() must not be null + */ +class KotlinArgumentCaptor constructor(clazz: Class) { + private val wrapped: ArgumentCaptor = ArgumentCaptor.forClass(clazz) + fun capture(): T = wrapped.capture() + val value: T + get() = wrapped.value + val allValues: List + get() = wrapped.allValues +} + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun kotlinArgumentCaptor(): KotlinArgumentCaptor = + KotlinArgumentCaptor(T::class.java) + +/** + * Helper function for creating and using a single-use ArgumentCaptor in kotlin. + * + * val captor = argumentCaptor() + * verify(...).someMethod(captor.capture()) + * val captured = captor.value + * + * becomes: + * + * val captured = withArgCaptor { verify(...).someMethod(capture()) } + * + * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + */ +inline fun withArgCaptor(block: KotlinArgumentCaptor.() -> Unit): T = + kotlinArgumentCaptor().apply { block() }.value + +/** + * Variant of [withArgCaptor] for capturing multiple arguments. + * + * val captor = argumentCaptor() + * verify(...).someMethod(captor.capture()) + * val captured: List = captor.allValues + * + * becomes: + * + * val capturedList = captureMany { verify(...).someMethod(capture()) } + */ +inline fun captureMany(block: KotlinArgumentCaptor.() -> Unit): List = + kotlinArgumentCaptor().apply{ block() }.allValues diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt new file mode 100644 index 00000000..052ad446 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.service.chooser.ChooserTarget +import com.android.intentresolver.chooser.ChooserTargetInfo +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val PACKAGE_A = "package.a" +private const val PACKAGE_B = "package.b" +private const val CLASS_NAME = "./MainActivity" + +class ShortcutSelectionLogicTest { + private val packageTargets = HashMap>().apply { + arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg -> + // shortcuts in reverse priority order + val targets = Array(3) { i -> + createChooserTarget( + "Shortcut $i", + (i + 1).toFloat() / 10f, + ComponentName(pkg, CLASS_NAME), + pkg.shortcutId(i), + ) + } + this[pkg] = targets + } + } + + private operator fun Map>.get(pkg: String, idx: Int) = + this[pkg]?.get(idx) ?: error("missing package $pkg") + + @Test + fun testAddShortcuts_no_limits() { + val serviceResults = ArrayList() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2, sc1), + serviceResults, + "Two shortcuts are expected as we do not apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_same_package_with_per_package_limit() { + val serviceResults = ArrayList() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2), + serviceResults, + "One shortcut is expected as we apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() { + val serviceResults = ArrayList() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 1, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc2), + serviceResults, + "One shortcut is expected as we apply overall shortcut limit" + ) + } + + @Test + fun testAddShortcuts_different_packages_with_per_package_limit() { + val serviceResults = ArrayList() + val pkgAsc1 = packageTargets[PACKAGE_A, 0] + val pkgAsc2 = packageTargets[PACKAGE_A, 1] + val pkgBsc1 = packageTargets[PACKAGE_B, 0] + val pkgBsc2 = packageTargets[PACKAGE_B, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + + testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(pkgAsc1, pkgAsc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.2f, + /* targets = */ listOf(pkgBsc1, pkgBsc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertShortcutsInOrder( + listOf(pkgBsc2, pkgAsc2), + serviceResults, + "Two shortcuts are expected as we apply per-app shortcut limit" + ) + } + + @Test + fun testAddShortcuts_pinned_shortcut() { + val serviceResults = ArrayList() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false + ) + + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ mock(), + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1, sc2), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ mapOf( + sc1 to createShortcutInfo( + PACKAGE_A.shortcutId(1), + sc1.componentName, 1).apply { + addFlags(ShortcutInfo.FLAG_PINNED) + } + ), + /* userContext = */ mock(), + /* mSelectableTargetInfoCommunicator = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertTrue("Updates are expected", isUpdated) + assertShortcutsInOrder( + listOf(sc1, sc2), + serviceResults, + "Two shortcuts are expected as we do not apply per-app shortcut limit" + ) + } + + @Test + fun test_available_caller_shortcuts_count_is_limited() { + val serviceResults = ArrayList() + val sc1 = packageTargets[PACKAGE_A, 0] + val sc2 = packageTargets[PACKAGE_A, 1] + val sc3 = packageTargets[PACKAGE_A, 2] + val testSubject = ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + val targetInfoCommunicator = mock { + whenever(targetIntent).thenReturn(Intent()) + } + val context = mock { + whenever(packageManager).thenReturn(mock()) + } + + testSubject.addServiceResults( + /* origTarget = */ null, + /* origTargetScore = */ 0f, + /* targets = */ listOf(sc1, sc2, sc3), + /* isShortcutResult = */ false /*isShortcutResult*/, + /* directShareToShortcutInfos = */ emptyMap(), + /* userContext = */ context, + /* mSelectableTargetInfoCommunicator = */ targetInfoCommunicator, + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults + ) + + assertShortcutsInOrder( + listOf(sc3, sc2), + serviceResults, + "At most two caller-provided shortcuts are allowed" + ) + } + + private fun assertShortcutsInOrder( + expected: List, actual: List, msg: String? = "" + ) { + assertEquals(msg, expected.size, actual.size) + for (i in expected.indices) { + assertEquals( + "Unexpected item at position $i", + expected[i], + actual[i].chooserTarget + ) + } + } + + private fun String.shortcutId(id: Int) = "$this.$id" +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt new file mode 100644 index 00000000..5529e714 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.service.chooser.ChooserTarget +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +private const val PACKAGE = "org.package" + +class ShortcutToChooserTargetConverterTest { + private val testSubject = ShortcutToChooserTargetConverter() + private val ranks = arrayOf(3 ,7, 1 ,3) + private val shortcuts = ranks + .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> + val id = i + 1 + acc.add( + createShareShortcutInfo( + id = "id-$i", + componentName = ComponentName(PACKAGE, "Class$id"), + rank, + ) + ) + acc + } + + @Test + fun testConvertToChooserTarget_predictionService() { + val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } + val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) + val appTargetCache = HashMap() + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset = shortcuts.subList(1, shortcuts.size) + val expectedOrderSubset = intArrayOf(1, 2, 3) + val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) + appTargetCache.clear() + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + @Test + fun testConvertToChooserTarget_shortcutManager() { + val testSubject = ShortcutToChooserTargetConverter() + val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset: MutableList = java.util.ArrayList() + subset.add(shortcuts[1]) + subset.add(shortcuts[2]) + subset.add(shortcuts[3]) + val expectedOrderSubset = intArrayOf(2, 3, 1) + val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + private fun assertCorrectShortcutToChooserTargetConversion( + shortcuts: List, + chooserTargets: List, + expectedOrder: IntArray, + expectedScores: FloatArray, + ) { + assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) + for (i in chooserTargets.indices) { + val ct = chooserTargets[i] + val si = shortcuts[expectedOrder[i]].shortcutInfo + val cn = shortcuts[expectedOrder[i]].targetComponent + assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) + assertEquals(si.label, ct.title) + assertEquals(expectedScores[i], ct.score) + assertEquals(cn, ct.componentName) + } + } + + private fun assertAppTargetCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val target = cache[ct] + assertNotNull("AppTarget is missing", target) + } + } + + private fun assertShortcutInfoCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val si = cache[ct] + assertNotNull("AppTarget is missing", si) + } + } +} diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt new file mode 100644 index 00000000..f4b83249 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestHelpers.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.prediction.AppTarget +import android.app.prediction.AppTargetId +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.os.Bundle +import android.service.chooser.ChooserTarget +import org.mockito.Mockito.`when` as whenever + +internal fun createShareShortcutInfo( + id: String, + componentName: ComponentName, + rank: Int +): ShareShortcutInfo = + ShareShortcutInfo( + createShortcutInfo(id, componentName, rank), + componentName + ) + +internal fun createShortcutInfo( + id: String, + componentName: ComponentName, + rank: Int +): ShortcutInfo { + val context = mock() + whenever(context.packageName).thenReturn(componentName.packageName) + return ShortcutInfo.Builder(context, id) + .setShortLabel("Short Label $id") + .setLongLabel("Long Label $id") + .setActivity(componentName) + .setRank(rank) + .build() +} + +internal fun createAppTarget(shortcutInfo: ShortcutInfo) = + AppTarget( + AppTargetId(shortcutInfo.id), + shortcutInfo, + shortcutInfo.activity?.className ?: error("missing activity info") + ) + +internal fun createChooserTarget( + title: String, score: Float, componentName: ComponentName, shortcutId: String +): ChooserTarget = + ChooserTarget( + title, + null, + score, + componentName, + Bundle().apply { putString(Intent.EXTRA_SHORTCUT_ID, shortcutId) } + ) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index b901fc1e..089bb8bc 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1347,92 +1347,6 @@ public class UnbundledChooserActivityTest { is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); } - @Test - public void testConvertToChooserTarget_predictionService() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - List shortcuts = createShortcuts(activity); - - int[] expectedOrderAllShortcuts = {0, 1, 2, 3}; - float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.98f, 0.97f}; - - List chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, - null, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts); - - List subset = new ArrayList<>(); - subset.add(shortcuts.get(1)); - subset.add(shortcuts.get(2)); - subset.add(shortcuts.get(3)); - - int[] expectedOrderSubset = {1, 2, 3}; - float[] expectedScoreSubset = {0.99f, 0.98f, 0.97f}; - - chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset); - } - - @Test - public void testConvertToChooserTarget_shortcutManager() { - Intent sendIntent = createSendTextIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - List shortcuts = createShortcuts(activity); - - int[] expectedOrderAllShortcuts = {2, 0, 3, 1}; - float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.99f, 0.98f}; - - List chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, - null, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts); - - List subset = new ArrayList<>(); - subset.add(shortcuts.get(1)); - subset.add(shortcuts.get(2)); - subset.add(shortcuts.get(3)); - - int[] expectedOrderSubset = {2, 3, 1}; - float[] expectedScoreSubset = {1.0f, 0.99f, 0.98f}; - - chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); - assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset); - } - // This test is too long and too slow and should not be taken as an example for future tests. @Test @Ignore public void testDirectTargetSelectionLogging() throws InterruptedException { @@ -3092,21 +3006,6 @@ public class UnbundledChooserActivityTest { return shortcuts; } - private void assertCorrectShortcutToChooserTargetConversion(List shortcuts, - List chooserTargets, int[] expectedOrder, float[] expectedScores) { - assertEquals(expectedOrder.length, chooserTargets.size()); - for (int i = 0; i < chooserTargets.size(); i++) { - ChooserTarget ct = chooserTargets.get(i); - ShortcutInfo si = shortcuts.get(expectedOrder[i]).getShortcutInfo(); - ComponentName cn = shortcuts.get(expectedOrder[i]).getTargetComponent(); - - assertEquals(si.getId(), ct.getIntentExtras().getString(Intent.EXTRA_SHORTCUT_ID)); - assertEquals(si.getShortLabel(), ct.getTitle()); - assertThat(Math.abs(expectedScores[i] - ct.getScore()) < 0.000001, is(true)); - assertEquals(cn.flattenToString(), ct.getComponentName().flattenToString()); - } - } - private void markWorkProfileUserAvailable() { ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); } -- cgit v1.2.3-59-g8ed1b From 93eadc3993a768cfac005faed2dcf56093cae2d5 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 7 Oct 2022 14:19:26 -0400 Subject: Missing changes from fork CL ag/20001357. This is essentially porting the set of framework changes that either weren't synced to my local client at the time that the fork CL was cut, or else were merged only to framework since then, and not yet ported to IntentResolver. (Any future changes to framework Chooser should, hopefully, be accompanied by the parallel port CL at the time of submission.) This CL was constructed by a mostly-manual process: 1. Review the history of all framework changes under ../core/internal/app and ../core/internal/widget for changes in any files that have been ported to IntentResolver, and apply those diffs to the forked files (keeping in mind that each of these CLs could expand the scope beyond files that had been forked to-date). Also briefly review history in framework resources (no changes appear to be related to Chooser). 2. Review the set of diffs between relative paths in the IntentResolver package and the corresponding path resolved in the framework(*) to observe/enforce the expected set from ag/20001357 (effectively manually rolling back IntentResolver changes since fork; probably would've been better as a setup step, but:) 3. Manually re-apply IntentResolver-only changes that have been merged since the fork. 4. Review diffs in the IntentResolver package (i.e., this CL) to ensure that they're attributable to changes encountered in [1]. 5. Fix (pre-existing) lint errors in the changed files (only caught by presubmits after this CL was otherwise ready to upload / still good to address while we're here.) Re-build/test. Test: atest IntentResolverUnitTests Bug: 202166832 Change-Id: I23f4d4e763072abd0e9a4bb2d61fefc5f6b39830 --- .../android/intentresolver/ChooserActivity.java | 49 +++++++++---- .../android/intentresolver/ChooserListAdapter.java | 85 +++++----------------- .../android/intentresolver/ResolverActivity.java | 19 +++-- .../intentresolver/ResolverListAdapter.java | 82 ++++++++++++++------- .../android/intentresolver/SimpleIconFactory.java | 16 +++- .../intentresolver/chooser/DisplayResolveInfo.java | 11 +-- .../chooser/SelectableTargetInfo.java | 1 + .../android/intentresolver/chooser/TargetInfo.java | 11 +++ 8 files changed, 151 insertions(+), 123 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 5d169d24..d0e47562 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -26,6 +26,7 @@ import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.annotation.IntDef; +import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; @@ -140,6 +141,7 @@ import java.lang.annotation.RetentionPolicy; import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -149,7 +151,7 @@ import java.util.function.Supplier; /** * The Chooser Activity handles intent resolution specifically for sharing intents - - * for example, those generated by @see android.content.Intent#createChooser(Intent, CharSequence). + * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ public class ChooserActivity extends ResolverActivity implements @@ -1068,7 +1070,12 @@ public class ChooserActivity extends ResolverActivity implements } } - private ViewGroup createContentPreviewView(ViewGroup parent) { + /** + * Create a view that will be shown in the content preview area + * @param parent reference to the parent container where the view should be attached to + * @return content preview view + */ + protected ViewGroup createContentPreviewView(ViewGroup parent) { Intent targetIntent = getTargetIntent(); int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent); @@ -2067,6 +2074,7 @@ public class ChooserActivity extends ResolverActivity implements if (matchingShortcuts.isEmpty()) { continue; } + List chooserTargets = mShortcutToChooserTargetConverter .convertToChooserTarget( matchingShortcuts, @@ -2588,7 +2596,7 @@ public class ChooserActivity extends ResolverActivity implements boolean isExpandable = getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT && !isInMultiWindowMode(); - if (directShareHeight != 0 && isSendAction(getTargetIntent()) + if (directShareHeight != 0 && shouldShowContentPreview() && isExpandable) { // make sure to leave room for direct share 4->8 expansion int requiredExpansionHeight = @@ -2828,7 +2836,14 @@ public class ChooserActivity extends ResolverActivity implements return shouldShowTabs() && mMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() > 0 - && isSendAction(getTargetIntent()); + && shouldShowContentPreview(); + } + + /** + * @return true if we want to show the content preview area + */ + protected boolean shouldShowContentPreview() { + return isSendAction(getTargetIntent()); } private void updateStickyContentPreview() { @@ -2880,12 +2895,14 @@ public class ChooserActivity extends ResolverActivity implements private void startFinishAnimation() { View rootView = findRootView(); - rootView.startAnimation(new FinishAnimation(this, rootView)); + if (rootView != null) { + rootView.startAnimation(new FinishAnimation(this, rootView)); + } } private boolean maybeCancelFinishAnimation() { View rootView = findRootView(); - Animation animation = rootView.getAnimation(); + Animation animation = (rootView == null) ? null : rootView.getAnimation(); if (animation instanceof FinishAnimation) { boolean hasEnded = animation.hasEnded(); animation.cancel(); @@ -3159,7 +3176,7 @@ public class ChooserActivity extends ResolverActivity implements return 0; } - if (!isSendAction(getTargetIntent())) { + if (!shouldShowContentPreview()) { return 0; } @@ -3190,7 +3207,7 @@ public class ChooserActivity extends ResolverActivity implements // There can be at most one row in the listview, that is internally // a ViewGroup with 2 rows public int getServiceTargetRowCount() { - if (isSendAction(getTargetIntent()) + if (shouldShowContentPreview() && !ActivityManager.isLowRamDeviceStatic()) { return 1; } @@ -3649,6 +3666,7 @@ public class ChooserActivity extends ResolverActivity implements this.mRows = rows; this.mCellCountPerRow = cellCountPerRow; this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; + Arrays.fill(mCellVisibility, true); this.mListAdapterSupplier = listAdapterSupplier; } @@ -4012,11 +4030,13 @@ public class ChooserActivity extends ResolverActivity implements */ private static class FinishAnimation extends AlphaAnimation implements Animation.AnimationListener { + @Nullable private Activity mActivity; + @Nullable private View mRootView; private final float mFromAlpha; - FinishAnimation(Activity activity, View rootView) { + FinishAnimation(@NonNull Activity activity, @NonNull View rootView) { super(rootView.getAlpha(), 0.0f); mActivity = activity; mRootView = rootView; @@ -4040,7 +4060,9 @@ public class ChooserActivity extends ResolverActivity implements @Override public void cancel() { - mRootView.setAlpha(mFromAlpha); + if (mRootView != null) { + mRootView.setAlpha(mFromAlpha); + } cleanup(); super.cancel(); } @@ -4051,9 +4073,10 @@ public class ChooserActivity extends ResolverActivity implements @Override public void onAnimationEnd(Animation animation) { - if (mActivity != null) { - mActivity.finish(); - cleanup(); + Activity activity = mActivity; + cleanup(); + if (activity != null) { + activity.finish(); } } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 3d760129..5e508ed8 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -243,6 +243,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mListViewDataChanged = false; } + private void createPlaceHolders() { mServiceTargets.clear(); for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { @@ -252,8 +253,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate( - R.layout.resolve_grid_item, parent, false); + return mInflater.inflate(R.layout.resolve_grid_item, parent, false); } @Override @@ -266,25 +266,20 @@ public class ChooserListAdapter extends ResolverListAdapter { return; } - if (info instanceof DisplayResolveInfo) { + holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + holder.bindIcon(info); + if (info instanceof SelectableTargetInfo) { + // direct share targets should append the application name for a better readout + DisplayResolveInfo rInfo = ((SelectableTargetInfo) info).getDisplayResolveInfo(); + CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; + CharSequence extendedInfo = info.getExtendedInfo(); + String contentDescription = String.join(" ", info.getDisplayLabel(), + extendedInfo != null ? extendedInfo : "", appName); + holder.updateContentDescription(contentDescription); + } else if (info instanceof DisplayResolveInfo) { DisplayResolveInfo dri = (DisplayResolveInfo) info; - holder.bindLabel(dri.getDisplayLabel(), dri.getExtendedInfo(), alwaysShowSubLabel()); - startDisplayResolveInfoIconLoading(holder, dri); - } else { - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); - - if (info instanceof SelectableTargetInfo) { - SelectableTargetInfo selectableInfo = (SelectableTargetInfo) info; - // direct share targets should append the application name for a better readout - DisplayResolveInfo rInfo = selectableInfo.getDisplayResolveInfo(); - CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; - CharSequence extendedInfo = selectableInfo.getExtendedInfo(); - String contentDescription = String.join(" ", selectableInfo.getDisplayLabel(), - extendedInfo != null ? extendedInfo : "", appName); - holder.updateContentDescription(contentDescription); - startSelectableTargetInfoIconLoading(holder, selectableInfo); - } else { - holder.bindIcon(info); + if (!dri.hasDisplayIcon()) { + loadIcon(dri); } } @@ -325,32 +320,6 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - private void startDisplayResolveInfoIconLoading(ViewHolder holder, DisplayResolveInfo info) { - LoadIconTask task = (LoadIconTask) mIconLoaders.get(info); - if (task == null) { - task = new LoadIconTask(info, holder); - mIconLoaders.put(info, task); - task.execute(); - } else { - // The holder was potentially changed as the underlying items were - // reshuffled, so reset the target holder - task.setViewHolder(holder); - } - } - - private void startSelectableTargetInfoIconLoading( - ViewHolder holder, SelectableTargetInfo info) { - LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); - if (task == null) { - task = mTestLoadDirectShareTaskProvider == null - ? new LoadDirectShareIconTask(info) - : mTestLoadDirectShareTaskProvider.get(); - mIconLoaders.put(info, task); - task.loadIcon(); - } - task.setViewHolder(holder); - } - void updateAlphabeticalList() { new AsyncTask>() { @Override @@ -560,12 +529,11 @@ public class ChooserListAdapter extends ResolverListAdapter { List targets, @ChooserActivity.ShareTargetType int targetType, Map directShareToShortcutInfos) { - // Avoid inserting any potentially late results - if (mServiceTargets.size() == 1 - && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) { + // Avoid inserting any potentially late results. + if ((mServiceTargets.size() == 1) + && (mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo)) { return; } - boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; boolean isUpdated = mShortcutSelectionLogic.addServiceResults( @@ -619,7 +587,6 @@ public class ChooserListAdapter extends ResolverListAdapter { notifyDataSetChanged(); } - public ChooserTarget getChooserTargetForValue(int value) { return mServiceTargets.get(value).getChooserTarget(); } @@ -677,25 +644,11 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - /** - * An alias for onBindView to use with unit tests. - */ - @VisibleForTesting - public void testViewBind(View view, TargetInfo info, int position) { - onBindView(view, info, position); - } - - @VisibleForTesting - public void setTestLoadDirectShareTaskProvider(LoadDirectShareIconTaskProvider provider) { - mTestLoadDirectShareTaskProvider = provider; - } - /** * Necessary methods to communicate between {@link ChooserListAdapter} * and {@link ChooserActivity}. */ - @VisibleForTesting - public interface ChooserListCommunicator extends ResolverListCommunicator { + interface ChooserListCommunicator extends ResolverListCommunicator { int getMaxRankedTargets(); diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 453a6e84..6c013221 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -55,6 +55,7 @@ import android.content.pm.UserInfo; import android.content.res.Configuration; import android.content.res.TypedArray; import android.graphics.Insets; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -94,7 +95,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; - import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; @@ -1475,14 +1475,21 @@ public class ResolverActivity extends Activity implements mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList.get(0); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - ResolverListAdapter inactiveAdapter = mMultiProfilePagerAdapter.getInactiveListAdapter(); - DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0); + final ResolverListAdapter inactiveAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0); // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - ResolverListAdapter.LoadIconTask iconTask = inactiveAdapter.new LoadIconTask( - otherProfileResolveInfo, new ResolverListAdapter.ViewHolder(icon)); - iconTask.execute(); + inactiveAdapter.new LoadIconTask(otherProfileResolveInfo) { + @Override + protected void onPostExecute(Drawable drawable) { + if (!isDestroyed()) { + otherProfileResolveInfo.setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + } + }.execute(); ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 898d8c8e..251b157b 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -54,11 +54,13 @@ import android.widget.TextView; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; - import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -87,6 +89,8 @@ public class ResolverListAdapter extends BaseAdapter { private Runnable mPostListReadyRunnable; private final boolean mIsAudioCaptureDevice; private boolean mIsTabLoaded; + private final Map mIconLoaders = new HashMap<>(); + private final Map mLabelLoaders = new HashMap<>(); public ResolverListAdapter(Context context, List payloadIntents, Intent[] initialIntents, List rList, @@ -636,26 +640,47 @@ public class ResolverListAdapter extends BaseAdapter { if (info == null) { holder.icon.setImageDrawable( mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + holder.bindLabel("", "", false); return; } - if (info instanceof DisplayResolveInfo - && !((DisplayResolveInfo) info).hasDisplayLabel()) { - getLoadLabelTask((DisplayResolveInfo) info, holder).execute(); - } else { - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + if (info instanceof DisplayResolveInfo) { + DisplayResolveInfo dri = (DisplayResolveInfo) info; + boolean hasLabel = dri.hasDisplayLabel(); + holder.bindLabel( + dri.getDisplayLabel(), + dri.getExtendedInfo(), + hasLabel && alwaysShowSubLabel()); + holder.bindIcon(info); + if (!hasLabel) { + loadLabel(dri); + } + if (!dri.hasDisplayIcon()) { + loadIcon(dri); + } } + } - if (info instanceof DisplayResolveInfo - && !((DisplayResolveInfo) info).hasDisplayIcon()) { - new LoadIconTask((DisplayResolveInfo) info, holder).execute(); - } else { - holder.bindIcon(info); + protected final void loadIcon(DisplayResolveInfo info) { + LoadIconTask task = mIconLoaders.get(info); + if (task == null) { + task = new LoadIconTask((DisplayResolveInfo) info); + mIconLoaders.put(info, task); + task.execute(); } } - protected LoadLabelTask getLoadLabelTask(DisplayResolveInfo info, ViewHolder holder) { - return new LoadLabelTask(info, holder); + private void loadLabel(DisplayResolveInfo info) { + LoadLabelTask task = mLabelLoaders.get(info); + if (task == null) { + task = createLoadLabelTask(info); + mLabelLoaders.put(info, task); + task.execute(); + } + } + + protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { + return new LoadLabelTask(info); } public void onDestroy() { @@ -666,6 +691,16 @@ public class ResolverListAdapter extends BaseAdapter { if (mResolverListController != null) { mResolverListController.destroy(); } + cancelTasks(mIconLoaders.values()); + cancelTasks(mLabelLoaders.values()); + mIconLoaders.clear(); + mLabelLoaders.clear(); + } + + private void cancelTasks(Collection tasks) { + for (T task: tasks) { + task.cancel(false); + } } private static ColorMatrixColorFilter getSuspendedColorMatrix() { @@ -888,11 +923,9 @@ public class ResolverListAdapter extends BaseAdapter { protected class LoadLabelTask extends AsyncTask { private final DisplayResolveInfo mDisplayResolveInfo; - private final ViewHolder mHolder; - protected LoadLabelTask(DisplayResolveInfo dri, ViewHolder holder) { + protected LoadLabelTask(DisplayResolveInfo dri) { mDisplayResolveInfo = dri; - mHolder = holder; } @Override @@ -930,21 +963,22 @@ public class ResolverListAdapter extends BaseAdapter { @Override protected void onPostExecute(CharSequence[] result) { + if (mDisplayResolveInfo.hasDisplayLabel()) { + return; + } mDisplayResolveInfo.setDisplayLabel(result[0]); mDisplayResolveInfo.setExtendedInfo(result[1]); - mHolder.bindLabel(result[0], result[1], alwaysShowSubLabel()); + notifyDataSetChanged(); } } class LoadIconTask extends AsyncTask { protected final DisplayResolveInfo mDisplayResolveInfo; private final ResolveInfo mResolveInfo; - private ViewHolder mHolder; - LoadIconTask(DisplayResolveInfo dri, ViewHolder holder) { + LoadIconTask(DisplayResolveInfo dri) { mDisplayResolveInfo = dri; mResolveInfo = dri.getResolveInfo(); - mHolder = holder; } @Override @@ -958,17 +992,9 @@ public class ResolverListAdapter extends BaseAdapter { mResolverListCommunicator.updateProfileViewButton(); } else if (!mDisplayResolveInfo.hasDisplayIcon()) { mDisplayResolveInfo.setDisplayIcon(d); - mHolder.bindIcon(mDisplayResolveInfo); - // Notify in case view is already bound to resolve the race conditions on - // low end devices notifyDataSetChanged(); } } - - public void setViewHolder(ViewHolder holder) { - mHolder = holder; - mHolder.bindIcon(mDisplayResolveInfo); - } } /** diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index b05b4f68..ec5179ac 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -50,6 +50,8 @@ import android.util.AttributeSet; import android.util.Pools.SynchronizedPool; import android.util.TypedValue; +import com.android.internal.annotations.VisibleForTesting; + import org.xmlpull.v1.XmlPullParser; import java.nio.ByteBuffer; @@ -67,6 +69,7 @@ public class SimpleIconFactory { private static final SynchronizedPool sPool = new SynchronizedPool<>(Runtime.getRuntime().availableProcessors()); + private static boolean sPoolEnabled = true; private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; private static final float BLUR_FACTOR = 1.5f / 48; @@ -90,7 +93,7 @@ public class SimpleIconFactory { */ @Deprecated public static SimpleIconFactory obtain(Context ctx) { - SimpleIconFactory instance = sPool.acquire(); + SimpleIconFactory instance = sPoolEnabled ? sPool.acquire() : null; if (instance == null) { final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE); final int iconDpi = (am == null) ? 0 : am.getLauncherLargeIconDensity(); @@ -104,6 +107,17 @@ public class SimpleIconFactory { return instance; } + /** + * Enables or disables SimpleIconFactory objects pooling. It is enabled in production, you + * could use this method in tests and disable the pooling to make the icon rendering more + * deterministic because some sizing parameters will not be cached. Please ensure that you + * reset this value back after finishing the test. + */ + @VisibleForTesting + public static void setPoolEnabled(boolean poolEnabled) { + sPoolEnabled = poolEnabled; + } + private static int getAttrDimFromContext(Context ctx, @AttrRes int attrId, String errorMsg) { final Resources res = ctx.getResources(); TypedValue outVal = new TypedValue(); diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index e7ffe3c6..c4bca266 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -172,14 +172,14 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { @Override public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); + TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); activity.startActivityAsCaller(mResolvedIntent, options, false, userId); return true; } @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); activity.startActivityAsUser(mResolvedIntent, options, user); return false; } @@ -224,13 +224,6 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { } }; - private static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) { - final int currentUserId = UserHandle.myUserId(); - if (targetUserId != currentUserId) { - intent.fixUris(currentUserId); - } - } - private DisplayResolveInfo(Parcel in) { mDisplayLabel = in.readCharSequence(); mExtendedInfo = in.readCharSequence(); diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index dffe2f6c..179966ad 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -259,6 +259,7 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { } intent.setComponent(mChooserTarget.getComponentName()); intent.putExtras(mChooserTarget.getIntentExtras()); + TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); // Important: we will ignore the target security checks in ActivityManager // if and only if the ChooserTarget's target package is the same package diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index fabb26c2..e1970354 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -130,4 +130,15 @@ public interface TargetInfo { * @return true if this target should be pinned to the front by the request of the user */ boolean isPinned(); + + /** + * Fix the URIs in {@code intent} if cross-profile sharing is required. This should be called + * before launching the intent as another user. + */ + static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) { + final int currentUserId = UserHandle.myUserId(); + if (targetUserId != currentUserId) { + intent.fixUris(currentUserId); + } + } } -- cgit v1.2.3-59-g8ed1b From c168f984875ff096720e0a2b092f14c601b5102f Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 5 Oct 2022 12:59:07 -0400 Subject: Switch off sharing framework's "widgets" This forks the ResolverDrawerLayout, which appears to be custom and specific to our app. Any other dependencies from the framework package com.android.internal.widget were old copies of androidx libraries, and the "unbundled" IntentResolver is able to take dependencies on the real versions instead. No dependencies into that framework package remain after this CL. These changes were excluded from ag/20001357 (as described in go/chooser-fork-cl) in order to isolate any possible regressions (and also to split up the review burden for the original fork CL). There are no behavior changes *expected* from this CL, but any edge-case discrepancies are hopefully improvements due to bugfixes or other refinements that were put into the androidx libraries after the framework copy was cut. The APIs themselves are supposed to be drop-in compatible. Test: atest IntentResolverUnitTests Bug: 248566504 Change-Id: I8e02fd580d395446066fc2bb239a9bef833ef059 --- Android.bp | 2 + java/res/layout/chooser_dialog.xml | 4 +- java/res/layout/chooser_grid.xml | 11 +- java/res/layout/chooser_list_per_profile.xml | 5 +- java/res/layout/miniresolver.xml | 19 +- java/res/layout/resolver_different_item_header.xml | 3 +- java/res/layout/resolver_list.xml | 21 +- java/res/layout/resolver_list_with_default.xml | 19 +- .../AbstractMultiProfilePagerAdapter.java | 5 +- .../android/intentresolver/ChooserActivity.java | 9 +- .../intentresolver/ChooserGridLayoutManager.java | 4 +- .../ChooserMultiProfilePagerAdapter.java | 7 +- .../ChooserRecyclerViewAccessibilityDelegate.java | 4 +- .../ChooserTargetActionsDialogFragment.java | 4 +- .../android/intentresolver/ResolverActivity.java | 5 +- .../ResolverMultiProfilePagerAdapter.java | 3 +- .../android/intentresolver/ResolverViewPager.java | 11 +- .../widget/ResolverDrawerLayout.java | 1223 ++++++++++++++++++++ .../UnbundledChooserActivityTest.java | 4 +- 19 files changed, 1295 insertions(+), 68 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 2407fc72..a8e64863 100644 --- a/Android.bp +++ b/Android.bp @@ -45,6 +45,8 @@ android_library { static_libs: [ "androidx.annotation_annotation", + "androidx.recyclerview_recyclerview", + "androidx.viewpager_viewpager", "unsupportedappusage", ], diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml index ff66bbb9..e31712c7 100644 --- a/java/res/layout/chooser_dialog.xml +++ b/java/res/layout/chooser_dialog.xml @@ -50,9 +50,9 @@ - - @@ -31,7 +32,7 @@ android:id="@androidprv:id/chooser_header" android:layout_width="match_parent" android:layout_height="wrap_content" - androidprv:layout_alwaysShow="true" + app:layout_alwaysShow="true" android:elevation="0dp" android:background="@drawable/bottomsheet_background"> @@ -94,4 +95,4 @@ - + diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml index 8d876cdf..1753e2f6 100644 --- a/java/res/layout/chooser_list_per_profile.xml +++ b/java/res/layout/chooser_list_per_profile.xml @@ -16,12 +16,13 @@ - - + app:layout_ignoreOffset="true"> - + diff --git a/java/res/layout/resolver_different_item_header.xml b/java/res/layout/resolver_different_item_header.xml index 4f801597..79ce6824 100644 --- a/java/res/layout/resolver_different_item_header.xml +++ b/java/res/layout/resolver_different_item_header.xml @@ -19,9 +19,10 @@ - + app:layout_ignoreOffset="true"> - + diff --git a/java/res/layout/resolver_list_with_default.xml b/java/res/layout/resolver_list_with_default.xml index 341c58e7..192a5983 100644 --- a/java/res/layout/resolver_list_with_default.xml +++ b/java/res/layout/resolver_list_with_default.xml @@ -16,19 +16,20 @@ * limitations under the License. */ --> - - + diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 4f6c0bf1..0f0d1797 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -34,9 +34,10 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + 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; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index d0e47562..36b32f6a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -112,6 +112,10 @@ import android.widget.ImageView; import android.widget.Space; import android.widget.TextView; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.ChooserTargetInfo; @@ -121,16 +125,13 @@ import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.RecyclerView; -import com.android.internal.widget.ResolverDrawerLayout; -import com.android.internal.widget.ViewPager; import com.google.android.collect.Lists; diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java index 7c4b0c1f..5f373525 100644 --- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java +++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java @@ -19,8 +19,8 @@ package com.android.intentresolver; import android.content.Context; import android.util.AttributeSet; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.RecyclerView; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; /** * For a11y and per {@link RecyclerView#onInitializeAccessibilityNodeInfo}, override diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index da78fc81..62c14866 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -33,10 +33,11 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.PagerAdapter; -import com.android.internal.widget.RecyclerView; /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 67571b44..250b6827 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -22,8 +22,8 @@ import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; -import com.android.internal.widget.RecyclerView; -import com.android.internal.widget.RecyclerViewAccessibilityDelegate; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { private final Rect mTempRect = new Rect(); diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index ffd173c7..b4a102ae 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -49,9 +49,9 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; -import com.android.intentresolver.chooser.DisplayResolveInfo; +import androidx.recyclerview.widget.RecyclerView; -import com.android.internal.widget.RecyclerView; +import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.ArrayList; import java.util.List; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 6c013221..ea140dcb 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -91,17 +91,18 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.viewpager.widget.ViewPager; + import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; import com.android.internal.util.LatencyTracker; -import com.android.internal.widget.ResolverDrawerLayout; -import com.android.internal.widget.ViewPager; import java.util.ArrayList; import java.util.Arrays; diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 56d326c1..7cd38a7e 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -32,8 +32,9 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ListView; +import androidx.viewpager.widget.PagerAdapter; + import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.widget.PagerAdapter; /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 1c234526..0804a2b8 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -21,7 +21,7 @@ import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; -import com.android.internal.widget.ViewPager; +import androidx.viewpager.widget.ViewPager; /** * A {@link ViewPager} which wraps around its tallest child's height. @@ -41,15 +41,6 @@ public class ResolverViewPager extends ViewPager { super(context, attrs); } - public ResolverViewPager(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ResolverViewPager(Context context, AttributeSet attrs, - int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java new file mode 100644 index 00000000..29821e66 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -0,0 +1,1223 @@ +/* + * Copyright (C) 2014 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.intentresolver.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.metrics.LogMaker; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.view.animation.AnimationUtils; +import android.widget.AbsListView; +import android.widget.OverScroller; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.R; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +public class ResolverDrawerLayout extends ViewGroup { + private static final String TAG = "ResolverDrawerLayout"; + private MetricsLogger mMetricsLogger; + + /** + * Max width of the whole drawer layout + */ + private final int mMaxWidth; + + /** + * Max total visible height of views not marked always-show when in the closed/initial state + */ + private int mMaxCollapsedHeight; + + /** + * Max total visible height of views not marked always-show when in the closed/initial state + * when a default option is present + */ + private int mMaxCollapsedHeightSmall; + + /** + * Whether {@code mMaxCollapsedHeightSmall} was set explicitly as a layout attribute or + * inferred by {@code mMaxCollapsedHeight}. + */ + private final boolean mIsMaxCollapsedHeightSmallExplicit; + + private boolean mSmallCollapsed; + + /** + * Move views down from the top by this much in px + */ + private float mCollapseOffset; + + /** + * Track fractions of pixels from drag calculations. Without this, the view offsets get + * out of sync due to frequently dropping fractions of a pixel from '(int) dy' casts. + */ + private float mDragRemainder = 0.0f; + private int mCollapsibleHeight; + private int mUncollapsibleHeight; + private int mAlwaysShowHeight; + + /** + * The height in pixels of reserved space added to the top of the collapsed UI; + * e.g. chooser targets + */ + private int mCollapsibleHeightReserved; + + private int mTopOffset; + private boolean mShowAtTop; + + private boolean mIsDragging; + private boolean mOpenOnClick; + private boolean mOpenOnLayout; + private boolean mDismissOnScrollerFinished; + private final int mTouchSlop; + private final float mMinFlingVelocity; + private final OverScroller mScroller; + private final VelocityTracker mVelocityTracker; + + private Drawable mScrollIndicatorDrawable; + + private OnDismissedListener mOnDismissedListener; + private RunOnDismissedListener mRunOnDismissedListener; + private OnCollapsedChangedListener mOnCollapsedChangedListener; + + private boolean mDismissLocked; + + private float mInitialTouchX; + private float mInitialTouchY; + private float mLastTouchY; + private int mActivePointerId = MotionEvent.INVALID_POINTER_ID; + + private final Rect mTempRect = new Rect(); + + private AbsListView mNestedListChild; + private RecyclerView mNestedRecyclerChild; + + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = + new ViewTreeObserver.OnTouchModeChangeListener() { + @Override + public void onTouchModeChanged(boolean isInTouchMode) { + if (!isInTouchMode && hasFocus() && isDescendantClipped(getFocusedChild())) { + smoothScrollTo(0, 0); + } + } + }; + + public ResolverDrawerLayout(Context context) { + this(context, null); + } + + public ResolverDrawerLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ResolverDrawerLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ResolverDrawerLayout, + defStyleAttr, 0); + mMaxWidth = a.getDimensionPixelSize(R.styleable.ResolverDrawerLayout_android_maxWidth, -1); + mMaxCollapsedHeight = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_maxCollapsedHeight, 0); + mMaxCollapsedHeightSmall = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall, + mMaxCollapsedHeight); + mIsMaxCollapsedHeightSmallExplicit = + a.hasValue(R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall); + mShowAtTop = a.getBoolean(R.styleable.ResolverDrawerLayout_showAtTop, false); + a.recycle(); + + mScrollIndicatorDrawable = mContext.getDrawable( + com.android.internal.R.drawable.scroll_indicator_material); + + mScroller = new OverScroller(context, AnimationUtils.loadInterpolator(context, + android.R.interpolator.decelerate_quint)); + mVelocityTracker = VelocityTracker.obtain(); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + + /** + * Dynamically set the max collapsed height. Note this also updates the small collapsed + * height if it wasn't specified explicitly. + */ + public void setMaxCollapsedHeight(int heightInPixels) { + if (heightInPixels == mMaxCollapsedHeight) { + return; + } + mMaxCollapsedHeight = heightInPixels; + if (!mIsMaxCollapsedHeightSmallExplicit) { + mMaxCollapsedHeightSmall = mMaxCollapsedHeight; + } + requestLayout(); + } + + public void setSmallCollapsed(boolean smallCollapsed) { + if (mSmallCollapsed != smallCollapsed) { + mSmallCollapsed = smallCollapsed; + requestLayout(); + } + } + + public boolean isSmallCollapsed() { + return mSmallCollapsed; + } + + public boolean isCollapsed() { + return mCollapseOffset > 0; + } + + public void setShowAtTop(boolean showOnTop) { + if (mShowAtTop != showOnTop) { + mShowAtTop = showOnTop; + requestLayout(); + } + } + + public boolean getShowAtTop() { + return mShowAtTop; + } + + public void setCollapsed(boolean collapsed) { + if (!isLaidOut()) { + mOpenOnLayout = !collapsed; + } else { + smoothScrollTo(collapsed ? mCollapsibleHeight : 0, 0); + } + } + + public void setCollapsibleHeightReserved(int heightPixels) { + final int oldReserved = mCollapsibleHeightReserved; + mCollapsibleHeightReserved = heightPixels; + if (oldReserved != mCollapsibleHeightReserved) { + requestLayout(); + } + + final int dReserved = mCollapsibleHeightReserved - oldReserved; + if (dReserved != 0 && mIsDragging) { + mLastTouchY -= dReserved; + } + + final int oldCollapsibleHeight = mCollapsibleHeight; + mCollapsibleHeight = Math.min(mCollapsibleHeight, getMaxCollapsedHeight()); + + if (updateCollapseOffset(oldCollapsibleHeight, !isDragging())) { + return; + } + + invalidate(); + } + + public void setDismissLocked(boolean locked) { + mDismissLocked = locked; + } + + private boolean isMoving() { + return mIsDragging || !mScroller.isFinished(); + } + + private boolean isDragging() { + return mIsDragging || getNestedScrollAxes() == SCROLL_AXIS_VERTICAL; + } + + private boolean updateCollapseOffset(int oldCollapsibleHeight, boolean remainClosed) { + if (oldCollapsibleHeight == mCollapsibleHeight) { + return false; + } + + if (getShowAtTop()) { + // Keep the drawer fully open. + setCollapseOffset(0); + return false; + } + + if (isLaidOut()) { + final boolean isCollapsedOld = mCollapseOffset != 0; + if (remainClosed && (oldCollapsibleHeight < mCollapsibleHeight + && mCollapseOffset == oldCollapsibleHeight)) { + // Stay closed even at the new height. + setCollapseOffset(mCollapsibleHeight); + } else { + setCollapseOffset(Math.min(mCollapseOffset, mCollapsibleHeight)); + } + final boolean isCollapsedNew = mCollapseOffset != 0; + if (isCollapsedOld != isCollapsedNew) { + onCollapsedChanged(isCollapsedNew); + } + } else { + // Start out collapsed at first unless we restored state for otherwise + setCollapseOffset(mOpenOnLayout ? 0 : mCollapsibleHeight); + } + return true; + } + + private void setCollapseOffset(float collapseOffset) { + if (mCollapseOffset != collapseOffset) { + mCollapseOffset = collapseOffset; + requestLayout(); + } + } + + private int getMaxCollapsedHeight() { + return (isSmallCollapsed() ? mMaxCollapsedHeightSmall : mMaxCollapsedHeight) + + mCollapsibleHeightReserved; + } + + public void setOnDismissedListener(OnDismissedListener listener) { + mOnDismissedListener = listener; + } + + private boolean isDismissable() { + return mOnDismissedListener != null && !mDismissLocked; + } + + public void setOnCollapsedChangedListener(OnCollapsedChangedListener listener) { + mOnCollapsedChangedListener = listener; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mVelocityTracker.clear(); + } + + mVelocityTracker.addMovement(ev); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + final float x = ev.getX(); + final float y = ev.getY(); + mInitialTouchX = x; + mInitialTouchY = mLastTouchY = y; + mOpenOnClick = isListChildUnderClipped(x, y) && mCollapseOffset > 0; + } + break; + + case MotionEvent.ACTION_MOVE: { + final float x = ev.getX(); + final float y = ev.getY(); + final float dy = y - mInitialTouchY; + if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null && + (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) { + mActivePointerId = ev.getPointerId(0); + mIsDragging = true; + mLastTouchY = Math.max(mLastTouchY - mTouchSlop, + Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop)); + } + } + break; + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(ev); + } + break; + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + resetTouch(); + } + break; + } + + if (mIsDragging) { + abortAnimation(); + } + return mIsDragging || mOpenOnClick; + } + + private boolean isNestedListChildScrolled() { + return mNestedListChild != null + && mNestedListChild.getChildCount() > 0 + && (mNestedListChild.getFirstVisiblePosition() > 0 + || mNestedListChild.getChildAt(0).getTop() < 0); + } + + private boolean isNestedRecyclerChildScrolled() { + if (mNestedRecyclerChild != null && mNestedRecyclerChild.getChildCount() > 0) { + final RecyclerView.ViewHolder vh = + mNestedRecyclerChild.findViewHolderForAdapterPosition(0); + return vh == null || vh.itemView.getTop() < 0; + } + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + + mVelocityTracker.addMovement(ev); + + boolean handled = false; + switch (action) { + case MotionEvent.ACTION_DOWN: { + final float x = ev.getX(); + final float y = ev.getY(); + mInitialTouchX = x; + mInitialTouchY = mLastTouchY = y; + mActivePointerId = ev.getPointerId(0); + final boolean hitView = findChildUnder(mInitialTouchX, mInitialTouchY) != null; + handled = isDismissable() || mCollapsibleHeight > 0; + mIsDragging = hitView && handled; + abortAnimation(); + } + break; + + case MotionEvent.ACTION_MOVE: { + int index = ev.findPointerIndex(mActivePointerId); + if (index < 0) { + Log.e(TAG, "Bad pointer id " + mActivePointerId + ", resetting"); + index = 0; + mActivePointerId = ev.getPointerId(0); + mInitialTouchX = ev.getX(); + mInitialTouchY = mLastTouchY = ev.getY(); + } + final float x = ev.getX(index); + final float y = ev.getY(index); + if (!mIsDragging) { + final float dy = y - mInitialTouchY; + if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null) { + handled = mIsDragging = true; + mLastTouchY = Math.max(mLastTouchY - mTouchSlop, + Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop)); + } + } + if (mIsDragging) { + final float dy = y - mLastTouchY; + if (dy > 0 && isNestedListChildScrolled()) { + mNestedListChild.smoothScrollBy((int) -dy, 0); + } else if (dy > 0 && isNestedRecyclerChildScrolled()) { + mNestedRecyclerChild.scrollBy(0, (int) -dy); + } else { + performDrag(dy); + } + } + mLastTouchY = y; + } + break; + + case MotionEvent.ACTION_POINTER_DOWN: { + final int pointerIndex = ev.getActionIndex(); + mActivePointerId = ev.getPointerId(pointerIndex); + mInitialTouchX = ev.getX(pointerIndex); + mInitialTouchY = mLastTouchY = ev.getY(pointerIndex); + } + break; + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(ev); + } + break; + + case MotionEvent.ACTION_UP: { + final boolean wasDragging = mIsDragging; + mIsDragging = false; + if (!wasDragging && findChildUnder(mInitialTouchX, mInitialTouchY) == null && + findChildUnder(ev.getX(), ev.getY()) == null) { + if (isDismissable()) { + dispatchOnDismissed(); + resetTouch(); + return true; + } + } + if (mOpenOnClick && Math.abs(ev.getX() - mInitialTouchX) < mTouchSlop && + Math.abs(ev.getY() - mInitialTouchY) < mTouchSlop) { + smoothScrollTo(0, 0); + return true; + } + mVelocityTracker.computeCurrentVelocity(1000); + final float yvel = mVelocityTracker.getYVelocity(mActivePointerId); + if (Math.abs(yvel) > mMinFlingVelocity) { + if (getShowAtTop()) { + if (isDismissable() && yvel < 0) { + abortAnimation(); + dismiss(); + } else { + smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel); + } + } else { + if (isDismissable() + && yvel > 0 && mCollapseOffset > mCollapsibleHeight) { + smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, yvel); + mDismissOnScrollerFinished = true; + } else { + scrollNestedScrollableChildBackToTop(); + smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel); + } + } + }else { + smoothScrollTo( + mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + resetTouch(); + } + break; + + case MotionEvent.ACTION_CANCEL: { + if (mIsDragging) { + smoothScrollTo( + mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + resetTouch(); + return true; + } + } + + return handled; + } + + /** + * Scroll nested scrollable child back to top if it has been scrolled. + */ + public void scrollNestedScrollableChildBackToTop() { + if (isNestedListChildScrolled()) { + mNestedListChild.smoothScrollToPosition(0); + } else if (isNestedRecyclerChildScrolled()) { + mNestedRecyclerChild.smoothScrollToPosition(0); + } + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mInitialTouchX = ev.getX(newPointerIndex); + mInitialTouchY = mLastTouchY = ev.getY(newPointerIndex); + mActivePointerId = ev.getPointerId(newPointerIndex); + } + } + + private void resetTouch() { + mActivePointerId = MotionEvent.INVALID_POINTER_ID; + mIsDragging = false; + mOpenOnClick = false; + mInitialTouchX = mInitialTouchY = mLastTouchY = 0; + mVelocityTracker.clear(); + } + + private void dismiss() { + mRunOnDismissedListener = new RunOnDismissedListener(); + post(mRunOnDismissedListener); + } + + @Override + public void computeScroll() { + super.computeScroll(); + if (mScroller.computeScrollOffset()) { + final boolean keepGoing = !mScroller.isFinished(); + performDrag(mScroller.getCurrY() - mCollapseOffset); + if (keepGoing) { + postInvalidateOnAnimation(); + } else if (mDismissOnScrollerFinished && mOnDismissedListener != null) { + dismiss(); + } + } + } + + private void abortAnimation() { + mScroller.abortAnimation(); + mRunOnDismissedListener = null; + mDismissOnScrollerFinished = false; + } + + private float performDrag(float dy) { + if (getShowAtTop()) { + return 0; + } + + final float newPos = Math.max(0, Math.min(mCollapseOffset + dy, + mCollapsibleHeight + mUncollapsibleHeight)); + if (newPos != mCollapseOffset) { + dy = newPos - mCollapseOffset; + + mDragRemainder += dy - (int) dy; + if (mDragRemainder >= 1.0f) { + mDragRemainder -= 1.0f; + dy += 1.0f; + } else if (mDragRemainder <= -1.0f) { + mDragRemainder += 1.0f; + dy -= 1.0f; + } + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.ignoreOffset) { + child.offsetTopAndBottom((int) dy); + } + } + final boolean isCollapsedOld = mCollapseOffset != 0; + mCollapseOffset = newPos; + mTopOffset += dy; + final boolean isCollapsedNew = newPos != 0; + if (isCollapsedOld != isCollapsedNew) { + onCollapsedChanged(isCollapsedNew); + getMetricsLogger().write( + new LogMaker(MetricsEvent.ACTION_SHARESHEET_COLLAPSED_CHANGED) + .setSubtype(isCollapsedNew ? 1 : 0)); + } + onScrollChanged(0, (int) newPos, 0, (int) (newPos - dy)); + postInvalidateOnAnimation(); + return dy; + } + return 0; + } + + private void onCollapsedChanged(boolean isCollapsed) { + notifyViewAccessibilityStateChangedIfNeeded( + AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED); + + if (mScrollIndicatorDrawable != null) { + setWillNotDraw(!isCollapsed); + } + + if (mOnCollapsedChangedListener != null) { + mOnCollapsedChangedListener.onCollapsedChanged(isCollapsed); + } + } + + void dispatchOnDismissed() { + if (mOnDismissedListener != null) { + mOnDismissedListener.onDismissed(); + } + if (mRunOnDismissedListener != null) { + removeCallbacks(mRunOnDismissedListener); + mRunOnDismissedListener = null; + } + } + + private void smoothScrollTo(int yOffset, float velocity) { + abortAnimation(); + final int sy = (int) mCollapseOffset; + int dy = yOffset - sy; + if (dy == 0) { + return; + } + + final int height = getHeight(); + final int halfHeight = height / 2; + final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dy) / height); + final float distance = halfHeight + halfHeight * + distanceInfluenceForSnapDuration(distanceRatio); + + int duration = 0; + velocity = Math.abs(velocity); + if (velocity > 0) { + duration = 4 * Math.round(1000 * Math.abs(distance / velocity)); + } else { + final float pageDelta = (float) Math.abs(dy) / height; + duration = (int) ((pageDelta + 1) * 100); + } + duration = Math.min(duration, 300); + + mScroller.startScroll(0, sy, 0, dy, duration); + postInvalidateOnAnimation(); + } + + private float distanceInfluenceForSnapDuration(float f) { + f -= 0.5f; // center the values about 0. + f *= 0.3f * Math.PI / 2.0f; + return (float) Math.sin(f); + } + + /** + * Note: this method doesn't take Z into account for overlapping views + * since it is only used in contexts where this doesn't affect the outcome. + */ + private View findChildUnder(float x, float y) { + return findChildUnder(this, x, y); + } + + private static View findChildUnder(ViewGroup parent, float x, float y) { + final int childCount = parent.getChildCount(); + for (int i = childCount - 1; i >= 0; i--) { + final View child = parent.getChildAt(i); + if (isChildUnder(child, x, y)) { + return child; + } + } + return null; + } + + private View findListChildUnder(float x, float y) { + View v = findChildUnder(x, y); + while (v != null) { + x -= v.getX(); + y -= v.getY(); + if (v instanceof AbsListView) { + // One more after this. + return findChildUnder((ViewGroup) v, x, y); + } + v = v instanceof ViewGroup ? findChildUnder((ViewGroup) v, x, y) : null; + } + return v; + } + + /** + * This only checks clipping along the bottom edge. + */ + private boolean isListChildUnderClipped(float x, float y) { + final View listChild = findListChildUnder(x, y); + return listChild != null && isDescendantClipped(listChild); + } + + private boolean isDescendantClipped(View child) { + mTempRect.set(0, 0, child.getWidth(), child.getHeight()); + offsetDescendantRectToMyCoords(child, mTempRect); + View directChild; + if (child.getParent() == this) { + directChild = child; + } else { + View v = child; + ViewParent p = child.getParent(); + while (p != this) { + v = (View) p; + p = v.getParent(); + } + directChild = v; + } + + // ResolverDrawerLayout lays out vertically in child order; + // the next view and forward is what to check against. + int clipEdge = getHeight() - getPaddingBottom(); + final int childCount = getChildCount(); + for (int i = indexOfChild(directChild) + 1; i < childCount; i++) { + final View nextChild = getChildAt(i); + if (nextChild.getVisibility() == GONE) { + continue; + } + clipEdge = Math.min(clipEdge, nextChild.getTop()); + } + return mTempRect.bottom > clipEdge; + } + + private static boolean isChildUnder(View child, float x, float y) { + final float left = child.getX(); + final float top = child.getY(); + final float right = left + child.getWidth(); + final float bottom = top + child.getHeight(); + return x >= left && y >= top && x < right && y < bottom; + } + + @Override + public void requestChildFocus(View child, View focused) { + super.requestChildFocus(child, focused); + if (!isInTouchMode() && isDescendantClipped(focused)) { + smoothScrollTo(0, 0); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + getViewTreeObserver().addOnTouchModeChangeListener(mTouchModeChangeListener); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + getViewTreeObserver().removeOnTouchModeChangeListener(mTouchModeChangeListener); + abortAnimation(); + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + if ((nestedScrollAxes & View.SCROLL_AXIS_VERTICAL) != 0) { + if (target instanceof AbsListView) { + mNestedListChild = (AbsListView) target; + } + if (target instanceof RecyclerView) { + mNestedRecyclerChild = (RecyclerView) target; + } + return true; + } + return false; + } + + @Override + public void onNestedScrollAccepted(View child, View target, int axes) { + super.onNestedScrollAccepted(child, target, axes); + } + + @Override + public void onStopNestedScroll(View child) { + super.onStopNestedScroll(child); + if (mScroller.isFinished()) { + smoothScrollTo(mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0); + } + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + if (dyUnconsumed < 0) { + performDrag(-dyUnconsumed); + } + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + if (dy > 0) { + consumed[1] = (int) -performDrag(-dy); + } + } + + @Override + public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { + smoothScrollTo(0, velocityY); + return true; + } + return false; + } + + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (!consumed && Math.abs(velocityY) > mMinFlingVelocity) { + if (getShowAtTop()) { + if (isDismissable() && velocityY > 0) { + abortAnimation(); + dismiss(); + } else { + smoothScrollTo(velocityY < 0 ? mCollapsibleHeight : 0, velocityY); + } + } else { + if (isDismissable() + && velocityY < 0 && mCollapseOffset > mCollapsibleHeight) { + smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, velocityY); + mDismissOnScrollerFinished = true; + } else { + smoothScrollTo(velocityY > 0 ? 0 : mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + private boolean performAccessibilityActionCommon(int action) { + switch (action) { + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + case AccessibilityNodeInfo.ACTION_EXPAND: + case com.android.internal.R.id.accessibilityActionScrollDown: + if (mCollapseOffset != 0) { + smoothScrollTo(0, 0); + return true; + } + break; + case AccessibilityNodeInfo.ACTION_COLLAPSE: + if (mCollapseOffset < mCollapsibleHeight) { + smoothScrollTo(mCollapsibleHeight, 0); + return true; + } + break; + case AccessibilityNodeInfo.ACTION_DISMISS: + if ((mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight) + && isDismissable()) { + smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, 0); + mDismissOnScrollerFinished = true; + return true; + } + break; + } + + return false; + } + + @Override + public boolean onNestedPrePerformAccessibilityAction(View target, int action, Bundle args) { + if (super.onNestedPrePerformAccessibilityAction(target, action, args)) { + return true; + } + + return performAccessibilityActionCommon(action); + } + + @Override + public CharSequence getAccessibilityClassName() { + // Since we support scrolling, make this ViewGroup look like a + // ScrollView. This is kind of a hack until we have support for + // specifying auto-scroll behavior. + return android.widget.ScrollView.class.getName(); + } + + @Override + public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfoInternal(info); + + if (isEnabled()) { + if (mCollapseOffset != 0) { + info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityAction.ACTION_EXPAND); + info.addAction(AccessibilityAction.ACTION_SCROLL_DOWN); + info.setScrollable(true); + } + if ((mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight) + && ((mCollapseOffset < mCollapsibleHeight) || isDismissable())) { + info.addAction(AccessibilityAction.ACTION_SCROLL_UP); + info.setScrollable(true); + } + if (mCollapseOffset < mCollapsibleHeight) { + info.addAction(AccessibilityAction.ACTION_COLLAPSE); + } + if (mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight && isDismissable()) { + info.addAction(AccessibilityAction.ACTION_DISMISS); + } + } + + // This view should never get accessibility focus, but it's interactive + // via nested scrolling, so we can't hide it completely. + info.removeAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS); + } + + @Override + public boolean performAccessibilityActionInternal(int action, Bundle arguments) { + if (action == AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS.getId()) { + // This view should never get accessibility focus. + return false; + } + + if (super.performAccessibilityActionInternal(action, arguments)) { + return true; + } + + return performAccessibilityActionCommon(action); + } + + @Override + public void onDrawForeground(Canvas canvas) { + if (mScrollIndicatorDrawable != null) { + mScrollIndicatorDrawable.draw(canvas); + } + + super.onDrawForeground(canvas); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int sourceWidth = MeasureSpec.getSize(widthMeasureSpec); + int widthSize = sourceWidth; + final int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + // Single-use layout; just ignore the mode and use available space. + // Clamp to maxWidth. + if (mMaxWidth >= 0) { + widthSize = Math.min(widthSize, mMaxWidth + getPaddingLeft() + getPaddingRight()); + } + + final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); + final int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY); + + // Currently we allot more height than is really needed so that the entirety of the + // sheet may be pulled up. + // TODO: Restrict the height here to be the right value. + int heightUsed = 0; + + // Measure always-show children first. + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (lp.alwaysShow && child.getVisibility() != GONE) { + if (lp.maxHeight != -1) { + final int remainingHeight = heightSize - heightUsed; + measureChildWithMargins(child, widthSpec, 0, + MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST), + lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0); + } else { + measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed); + } + heightUsed += child.getMeasuredHeight(); + } + } + + mAlwaysShowHeight = heightUsed; + + // And now the rest. + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.alwaysShow && child.getVisibility() != GONE) { + if (lp.maxHeight != -1) { + final int remainingHeight = heightSize - heightUsed; + measureChildWithMargins(child, widthSpec, 0, + MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST), + lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0); + } else { + measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed); + } + heightUsed += child.getMeasuredHeight(); + } + } + + final int oldCollapsibleHeight = mCollapsibleHeight; + mCollapsibleHeight = Math.max(0, + heightUsed - mAlwaysShowHeight - getMaxCollapsedHeight()); + mUncollapsibleHeight = heightUsed - mCollapsibleHeight; + + updateCollapseOffset(oldCollapsibleHeight, !isDragging()); + + if (getShowAtTop()) { + mTopOffset = 0; + } else { + mTopOffset = Math.max(0, heightSize - heightUsed) + (int) mCollapseOffset; + } + + setMeasuredDimension(sourceWidth, heightSize); + } + + /** + * @return The space reserved by views with 'alwaysShow=true' + */ + public int getAlwaysShowHeight() { + return mAlwaysShowHeight; + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int width = getWidth(); + + View indicatorHost = null; + + int ypos = mTopOffset; + final int leftEdge = getPaddingLeft(); + final int rightEdge = width - getPaddingRight(); + final int widthAvailable = rightEdge - leftEdge; + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (lp.hasNestedScrollIndicator) { + indicatorHost = child; + } + + if (child.getVisibility() == GONE) { + continue; + } + + int top = ypos + lp.topMargin; + if (lp.ignoreOffset) { + top -= mCollapseOffset; + } + final int bottom = top + child.getMeasuredHeight(); + + final int childWidth = child.getMeasuredWidth(); + final int left = leftEdge + (widthAvailable - childWidth) / 2; + final int right = left + childWidth; + + child.layout(left, top, right, bottom); + + ypos = bottom + lp.bottomMargin; + } + + if (mScrollIndicatorDrawable != null) { + if (indicatorHost != null) { + final int left = indicatorHost.getLeft(); + final int right = indicatorHost.getRight(); + final int bottom = indicatorHost.getTop(); + final int top = bottom - mScrollIndicatorDrawable.getIntrinsicHeight(); + mScrollIndicatorDrawable.setBounds(left, top, right, bottom); + setWillNotDraw(!isCollapsed()); + } else { + mScrollIndicatorDrawable = null; + setWillNotDraw(true); + } + } + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + if (p instanceof LayoutParams) { + return new LayoutParams((LayoutParams) p); + } else if (p instanceof MarginLayoutParams) { + return new LayoutParams((MarginLayoutParams) p); + } + return new LayoutParams(p); + } + + @Override + protected ViewGroup.LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + + @Override + protected Parcelable onSaveInstanceState() { + final SavedState ss = new SavedState(super.onSaveInstanceState()); + ss.open = mCollapsibleHeight > 0 && mCollapseOffset == 0; + ss.mCollapsibleHeightReserved = mCollapsibleHeightReserved; + return ss; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + final SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mOpenOnLayout = ss.open; + mCollapsibleHeightReserved = ss.mCollapsibleHeightReserved; + } + + public static class LayoutParams extends MarginLayoutParams { + public boolean alwaysShow; + public boolean ignoreOffset; + public boolean hasNestedScrollIndicator; + public int maxHeight; + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + + final TypedArray a = c.obtainStyledAttributes(attrs, + R.styleable.ResolverDrawerLayout_LayoutParams); + alwaysShow = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_alwaysShow, + false); + ignoreOffset = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_ignoreOffset, + false); + hasNestedScrollIndicator = a.getBoolean( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_hasNestedScrollIndicator, + false); + maxHeight = a.getDimensionPixelSize( + R.styleable.ResolverDrawerLayout_LayoutParams_layout_maxHeight, -1); + a.recycle(); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(LayoutParams source) { + super(source); + this.alwaysShow = source.alwaysShow; + this.ignoreOffset = source.ignoreOffset; + this.hasNestedScrollIndicator = source.hasNestedScrollIndicator; + this.maxHeight = source.maxHeight; + } + + public LayoutParams(MarginLayoutParams source) { + super(source); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + } + + static class SavedState extends BaseSavedState { + boolean open; + private int mCollapsibleHeightReserved; + + SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + open = in.readInt() != 0; + mCollapsibleHeightReserved = in.readInt(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(open ? 1 : 0); + out.writeInt(mCollapsibleHeightReserved); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * Listener for sheet dismissed events. + */ + public interface OnDismissedListener { + /** + * Callback when the sheet is dismissed by the user. + */ + void onDismissed(); + } + + /** + * Listener for sheet collapsed / expanded events. + */ + public interface OnCollapsedChangedListener { + /** + * Callback when the sheet is either fully expanded or collapsed. + * @param isCollapsed true when collapsed, false when expanded. + */ + void onCollapsedChanged(boolean isCollapsed); + } + + private class RunOnDismissedListener implements Runnable { + @Override + public void run() { + dispatchOnDismissed(); + } + } + + private MetricsLogger getMetricsLogger() { + if (mMetricsLogger == null) { + mMetricsLogger = new MetricsLogger(); + } + return mMetricsLogger; + } +} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 7a590584..9ac815f6 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -86,6 +86,8 @@ import android.view.View; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; @@ -96,8 +98,6 @@ import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.android.internal.widget.GridLayoutManager; -import com.android.internal.widget.RecyclerView; import org.hamcrest.Description; import org.hamcrest.Matcher; -- cgit v1.2.3-59-g8ed1b From 93d10c4ef29b35aeee4cbbe01d2feda2dc8e6d67 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 5 Oct 2022 11:43:30 -0700 Subject: Change ResolverActivity base class to FragmentActivity Change ResolverActivity's base class to FragmentMnager to make lifecycle and view model related functionality available. Only a few Activity's methods used by either ResolverActivity or ChooserActivity are changed in the FragmentActivity hierarchy (except for lifecycle methods e.g. onCreate): - setContentView: adds support for view lifecycle; - setEnterSharedElemenCallabck: adds support for older SDK versions, no-op in our case; - getFragmentManager(): deprecated by FragmetnActivity, switched over to getSupportFragmentManager(). Test: manual general functinality test, pinning (ChooserTargetActionDialogFragment) Test: atest IntentResolverUnitTests Change-Id: If3b80c63facb9de04343409cf64db758700ec147 --- Android.bp | 3 ++- java/src/com/android/intentresolver/ChooserActivity.java | 6 ++---- .../android/intentresolver/ChooserTargetActionsDialogFragment.java | 2 +- java/src/com/android/intentresolver/IntentForwarderActivity.java | 3 --- java/src/com/android/intentresolver/ResolverActivity.java | 7 ++----- 5 files changed, 7 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/Android.bp b/Android.bp index a8e64863..2477d409 100644 --- a/Android.bp +++ b/Android.bp @@ -47,7 +47,8 @@ android_library { "androidx.annotation_annotation", "androidx.recyclerview_recyclerview", "androidx.viewpager_viewpager", - "unsupportedappusage", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", ], plugins: ["java_api_finder"], diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 36b32f6a..c4b7874c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -38,7 +38,6 @@ import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; -import android.compat.annotation.UnsupportedAppUsage; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ComponentName; @@ -164,7 +163,6 @@ public class ChooserActivity extends ResolverActivity implements private AppPredictor mWorkAppPredictor; private boolean mShouldDisplayLandscape; - @UnsupportedAppUsage public ChooserActivity() { } /** @@ -1760,7 +1758,7 @@ public class ChooserActivity extends ResolverActivity implements targetList); fragment.setArguments(bundle); - fragment.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); + fragment.show(getSupportFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); } private void modifyTargetIntent(Intent in) { @@ -1826,7 +1824,7 @@ public class ChooserActivity extends ResolverActivity implements b.putInt(ChooserStackedAppDialogFragment.WHICH_KEY, which); f.setArguments(b); - f.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); + f.show(getSupportFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); return; } } diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index b4a102ae..61b54fa9 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -27,7 +27,6 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager; import android.app.Dialog; -import android.app.DialogFragment; import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; @@ -49,6 +48,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.chooser.DisplayResolveInfo; diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 9b853c95..78240250 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -28,7 +28,6 @@ import android.app.Activity; import android.app.ActivityThread; import android.app.AppGlobals; import android.app.admin.DevicePolicyManager; -import android.compat.annotation.UnsupportedAppUsage; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Intent; @@ -38,7 +37,6 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.metrics.LogMaker; -import android.os.Build; import android.os.Bundle; import android.os.RemoteException; import android.os.UserHandle; @@ -65,7 +63,6 @@ import java.util.concurrent.Executors; * be passed in and out of a managed profile. */ public class IntentForwarderActivity extends Activity { - @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) public static String TAG = "IntentForwarderActivity"; public static String FORWARD_INTENT_TO_PARENT diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index ea140dcb..0733f4fa 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -39,7 +39,6 @@ import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; -import android.compat.annotation.UnsupportedAppUsage; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; @@ -91,6 +90,7 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; @@ -117,10 +117,9 @@ import java.util.Set; * which to go to. It is not normally used directly by application developers. */ @UiThread -public class ResolverActivity extends Activity implements +public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { - @UnsupportedAppUsage public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); } @@ -150,7 +149,6 @@ public class ResolverActivity extends Activity implements @VisibleForTesting protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; - @UnsupportedAppUsage protected PackageManager mPm; protected int mLaunchedFromUid; @@ -361,7 +359,6 @@ public class ResolverActivity extends Activity implements * Compatibility version for other bundled services that use this overload without * a default title resource */ - @UnsupportedAppUsage protected void onCreate(Bundle savedInstanceState, Intent intent, CharSequence title, Intent[] initialIntents, List rList, boolean supportsAlwaysUseOption) { -- cgit v1.2.3-59-g8ed1b From 7b77052941ba21afe1b0b73d7bcfe26695c392b8 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 13 Oct 2022 11:07:33 -0400 Subject: Remove "instanceof" checks in TargetInfo clients. Clients of the various TargetInfo subclasses use runtime type checks to dispatch different behavior depending on the target type. This CL makes minimal changes to replace these checks with explicit methods on the TargetInfo API to discourage clients from making inferences based on the specific types (while we're refactoring this hierarchy). The alternative provided here doesn't really improve the quality of the existing code (because the new query methods don't convey any application semantics other than the identity of the subtype like we were already using; and because the clients still use the results of these query methods to gate downcasting to the different concrete types, so they still remain "as aware" of the subclass design as ever). Arguably it even makes the code worse (because `instanceof` is a language feature, while these new APIs are hypothetically more prone to abuse that could result in unsafe downcasts). We expect to continue addressing all of these issues in followup CLs, but it's hard for maintainers to reason about the changes we can make as long as these kinds of type-checks are spread throughout client code. Once clients are generally only implemented in terms of the base TargetInfo API, we may choose to retain the overall inheritance model as an implementation convenience. Then the requirements of clients who currently type-check and downcast for type-specific dispatch would be better addressed either by moving their behavior into polymorphic methods in the TargetInfo API, or else perhaps by some sort of Visitor pattern that restructures the API while generally leaving the clients responsible for any type-specific logic they currently own. (Tentatively we might prefer an even simpler option where all the subclass APIs are collapsed into the base TargetInfo, and then there would be no need for "type-specific" behavior; more info can be found at go/chooser-targetinfo-cleanup). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ib3b3d7c4730a62243eb290a440bc0f14e4e31491 --- .../android/intentresolver/ChooserActivity.java | 32 ++++--- .../android/intentresolver/ChooserListAdapter.java | 17 ++-- .../android/intentresolver/ResolverActivity.java | 3 +- .../intentresolver/ResolverListAdapter.java | 2 +- .../intentresolver/chooser/ChooserTargetInfo.java | 33 ++++++- .../intentresolver/chooser/DisplayResolveInfo.java | 5 ++ .../chooser/MultiDisplayResolveInfo.java | 5 ++ .../chooser/NotSelectableTargetInfo.java | 5 +- .../chooser/SelectableTargetInfo.java | 7 +- .../android/intentresolver/chooser/TargetInfo.java | 100 +++++++++++++++++++++ 10 files changed, 180 insertions(+), 29 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 36b32f6a..83d4309c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1726,7 +1726,7 @@ public class ChooserActivity extends ResolverActivity implements ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(); Bundle bundle = new Bundle(); - if (targetInfo instanceof SelectableTargetInfo) { + if (targetInfo.isSelectableTargetInfo()) { SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; if (selectableTargetInfo.getDisplayResolveInfo() == null || selectableTargetInfo.getChooserTarget() == null) { @@ -1746,7 +1746,7 @@ public class ChooserActivity extends ResolverActivity implements bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, selectableTargetInfo.getDisplayLabel().toString()); } - } else if (targetInfo instanceof MultiDisplayResolveInfo) { + } else if (targetInfo.isMultiDisplayResolveInfo()) { // For multiple targets, include info on all targets MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; targetList = mti.getTargets(); @@ -1808,13 +1808,13 @@ public class ChooserActivity extends ResolverActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter .targetInfoForPosition(which, filtered); - if (targetInfo != null && targetInfo instanceof NotSelectableTargetInfo) { + if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { return; } final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - if (targetInfo instanceof MultiDisplayResolveInfo) { + if (targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { ChooserStackedAppDialogFragment f = new ChooserStackedAppDialogFragment(); @@ -2137,7 +2137,7 @@ public class ChooserActivity extends ResolverActivity implements } void updateModelAndChooserCounts(TargetInfo info) { - if (info != null && info instanceof MultiDisplayResolveInfo) { + if (info != null && info.isMultiDisplayResolveInfo()) { info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); } if (info != null) { @@ -2171,7 +2171,7 @@ public class ChooserActivity extends ResolverActivity implements return; } // Send DS target impression info to AppPredictor, only when user chooses app share. - if (targetInfo instanceof ChooserTargetInfo) { + if (targetInfo.isChooserTargetInfo()) { return; } List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); @@ -2195,7 +2195,7 @@ public class ChooserActivity extends ResolverActivity implements if (directShareAppPredictor == null) { return; } - if (!(targetInfo instanceof ChooserTargetInfo)) { + if (!targetInfo.isChooserTargetInfo()) { return; } ChooserTarget chooserTarget = ((ChooserTargetInfo) targetInfo).getChooserTarget(); @@ -2448,6 +2448,11 @@ public class ChooserActivity extends ResolverActivity implements } static final class PlaceHolderTargetInfo extends NotSelectableTargetInfo { + @Override + public boolean isPlaceHolderTargetInfo() { + return true; + } + public Drawable getDisplayIcon(Context context) { AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); @@ -2459,6 +2464,11 @@ public class ChooserActivity extends ResolverActivity implements protected static final class EmptyTargetInfo extends NotSelectableTargetInfo { public EmptyTargetInfo() {} + @Override + public boolean isEmptyTargetInfo() { + return true; + } + public Drawable getDisplayIcon(Context context) { return null; } @@ -2956,7 +2966,7 @@ public class ChooserActivity extends ResolverActivity implements .targetInfoForPosition(mListPosition, /* filtered */ true); // This should always be the case for ItemViewHolder, check for validity - if (ti instanceof DisplayResolveInfo && shouldShowTargetDetails(ti)) { + if (ti.isDisplayResolveInfo() && shouldShowTargetDetails(ti)) { showTargetDetails((DisplayResolveInfo) ti); } return true; @@ -2970,8 +2980,8 @@ public class ChooserActivity extends ResolverActivity implements // Suppress target details for nearby share to hide pin/unpin action boolean isNearbyShare = nearbyShare != null && nearbyShare.equals( ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow(); - return ti instanceof SelectableTargetInfo - || (ti instanceof DisplayResolveInfo && !isNearbyShare); + return ti.isSelectableTargetInfo() + || (ti.isDisplayResolveInfo() && !isNearbyShare); } /** @@ -3454,7 +3464,7 @@ public class ChooserActivity extends ResolverActivity implements end--; } - if (end == start && mChooserListAdapter.getItem(start) instanceof EmptyTargetInfo) { + if (end == start && mChooserListAdapter.getItem(start).isEmptyTargetInfo()) { final TextView textView = viewGroup.findViewById(com.android.internal.R.id.chooser_row_text_option); if (textView.getVisibility() != View.VISIBLE) { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 5e508ed8..0a8b3890 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -268,7 +268,7 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); holder.bindIcon(info); - if (info instanceof SelectableTargetInfo) { + if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = ((SelectableTargetInfo) info).getDisplayResolveInfo(); CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; @@ -276,7 +276,7 @@ public class ChooserListAdapter extends ResolverListAdapter { String contentDescription = String.join(" ", info.getDisplayLabel(), extendedInfo != null ? extendedInfo : "", appName); holder.updateContentDescription(contentDescription); - } else if (info instanceof DisplayResolveInfo) { + } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { loadIcon(dri); @@ -284,7 +284,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } // If target is loading, show a special placeholder shape in the label, make unclickable - if (info instanceof ChooserActivity.PlaceHolderTargetInfo) { + if (info.isPlaceHolderTargetInfo()) { final int maxWidth = mContext.getResources().getDimensionPixelSize( R.dimen.chooser_direct_share_label_placeholder_max_width); holder.text.setMaxWidth(maxWidth); @@ -301,7 +301,7 @@ public class ChooserListAdapter extends ResolverListAdapter { // Always remove the spacing listener, attach as needed to direct share targets below. holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); - if (info instanceof MultiDisplayResolveInfo) { + if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background); holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0); @@ -338,7 +338,7 @@ public class ChooserListAdapter extends ResolverListAdapter { DisplayResolveInfo multiDri = consolidated.get(resolvedTarget); if (multiDri == null) { consolidated.put(resolvedTarget, info); - } else if (multiDri instanceof MultiDisplayResolveInfo) { + } else if (multiDri.isMultiDisplayResolveInfo()) { ((MultiDisplayResolveInfo) multiDri).addTarget(info); } else { // create consolidated target from the single DisplayResolveInfo @@ -387,7 +387,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public int getSelectableServiceTargetCount() { int count = 0; for (ChooserTargetInfo info : mServiceTargets) { - if (info instanceof SelectableTargetInfo) { + if (info.isSelectableTargetInfo()) { count++; } } @@ -530,8 +530,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @ChooserActivity.ShareTargetType int targetType, Map directShareToShortcutInfos) { // Avoid inserting any potentially late results. - if ((mServiceTargets.size() == 1) - && (mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo)) { + if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) { return; } boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER @@ -579,7 +578,7 @@ public class ChooserListAdapter extends ResolverListAdapter { * update the direct share area. */ public void completeServiceTargetLoading() { - mServiceTargets.removeIf(o -> o instanceof ChooserActivity.PlaceHolderTargetInfo); + mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo()); if (mServiceTargets.isEmpty()) { mServiceTargets.add(new ChooserActivity.EmptyTargetInfo()); mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index ea140dcb..a0ece9d9 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -94,7 +94,6 @@ import android.widget.Toast; import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -1376,7 +1375,7 @@ public class ResolverActivity extends Activity implements .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) .setStrings(getMetricsCategory(), - cti instanceof ChooserTargetInfo ? "direct_share" : "other_target") + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 251b157b..3c8eae3a 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -644,7 +644,7 @@ public class ResolverListAdapter extends BaseAdapter { return; } - if (info instanceof DisplayResolveInfo) { + if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; boolean hasLabel = dri.hasDisplayLabel(); holder.bindLabel( diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 1c763071..77c30102 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,6 +16,7 @@ package com.android.intentresolver.chooser; +import android.annotation.Nullable; import android.service.chooser.ChooserTarget; import android.text.TextUtils; @@ -23,16 +24,40 @@ import android.text.TextUtils; * A TargetInfo for Direct Share. Includes a {@link ChooserTarget} representing the * Direct Share deep link into an application. */ -public interface ChooserTargetInfo extends TargetInfo { - float getModifiedScore(); +public abstract class ChooserTargetInfo implements TargetInfo { + /** + * @return the target score, including any Chooser-specific modifications that may have been + * applied (either overriding by special-case for "non-selectable" targets, or by twiddling the + * scores of "selectable" targets in {@link ChooserListAdapter}). Higher scores are "better." + */ + public abstract float getModifiedScore(); + + /** + * @return the {@link ChooserTarget} record that contains additional data about this target, if + * any. This is only non-null for selectable targets (and probably only Direct Share targets?). + * + * @deprecated {@link ChooserTarget} (and any other related {@code ChooserTargetService} APIs) + * got deprecated as part of sunsetting that old system design, but for historical reasons + * Chooser continues to shoehorn data from other sources into this representation to maintain + * compatibility with legacy internal APIs. New clients should avoid taking any further + * dependencies on the {@link ChooserTarget} type; any data they want to query from those + * records should instead be pulled up to new query methods directly on this class (or on the + * root {@link TargetInfo}). + */ + @Deprecated + @Nullable + public abstract ChooserTarget getChooserTarget(); - ChooserTarget getChooserTarget(); + @Override + public final boolean isChooserTargetInfo() { + return true; + } /** * Do not label as 'equals', since this doesn't quite work * as intended with java 8. */ - default boolean isSimilar(ChooserTargetInfo other) { + public boolean isSimilar(ChooserTargetInfo other) { if (other == null) return false; ChooserTarget ct1 = getChooserTarget(); diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index c4bca266..8f950323 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -100,6 +100,11 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; } + @Override + public final boolean isDisplayResolveInfo() { + return true; + } + public ResolveInfo getResolveInfo() { return mResolveInfo; } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 5133d997..e1fe58fd 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -44,6 +44,11 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { mTargetInfos.add(firstInfo); } + @Override + public final boolean isMultiDisplayResolveInfo() { + return true; + } + @Override public CharSequence getExtendedInfo() { // Never show subtitle for stacked apps diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 220870f2..db03b785 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -32,7 +32,10 @@ import java.util.List; * Distinguish between targets that selectable by the user, vs those that are * placeholders for the system while information is loading in an async manner. */ -public abstract class NotSelectableTargetInfo implements ChooserTargetInfo { +public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { + public final boolean isNotSelectableTargetInfo() { + return true; + } public Intent getResolvedIntent() { return null; diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 179966ad..0f48cfd1 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -50,7 +50,7 @@ import java.util.List; * Live target, currently selectable by the user. * @see NotSelectableTargetInfo */ -public final class SelectableTargetInfo implements ChooserTargetInfo { +public final class SelectableTargetInfo extends ChooserTargetInfo { private static final String TAG = "SelectableTargetInfo"; private final Context mContext; @@ -134,6 +134,11 @@ public final class SelectableTargetInfo implements ChooserTargetInfo { mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); } + @Override + public boolean isSelectableTargetInfo() { + return true; + } + private String sanitizeDisplayLabel(CharSequence label) { SpannableStringBuilder sb = new SpannableStringBuilder(label); sb.clearSpans(); diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index e1970354..502a3342 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -131,6 +131,106 @@ public interface TargetInfo { */ boolean isPinned(); + /** + * @return true if this target represents a legacy {@code ChooserTargetInfo}. These objects were + * historically documented as representing "[a] TargetInfo for Direct Share." However, not all + * of these targets are actually *valid* for direct share; e.g. some represent "empty" items + * (although perhaps only for display in the Direct Share UI?). {@link #getChooserTarget()} will + * return null for any of these "invalid" items. In even earlier versions, these targets may + * also have been results from (now-deprecated/unsupported) {@code ChooserTargetService} peers; + * even though we no longer use these services, we're still shoehorning other target data into + * the deprecated {@link ChooserTarget} structure for compatibility with some internal APIs. + * TODO: refactor to clarify the semantics of any target for which this method returns true + * (e.g., are they characterized by their application in the Direct Share UI?), and to remove + * the scaffolding that adapts to and from the {@link ChooserTarget} structure. Eventually, we + * expect to remove this method (and others that strictly indicate legacy subclass roles) in + * favor of a more semantic design that expresses the purpose and distinctions in those roles. + */ + default boolean isChooserTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code DisplayResolveInfo}. These objects + * were historically documented as an augmented "TargetInfo plus additional information needed + * to render it (such as icon and label) and resolve it to an activity." That description in no + * way distinguishes from the base {@code TargetInfo} API. At the time of writing, these objects + * are most-clearly defined by their opposite; this returns true for exactly those instances of + * {@code TargetInfo} where {@link #isChooserTargetInfo()} returns false (these conditions are + * complementary because they correspond to the immediate {@code TargetInfo} child types that + * historically partitioned all concrete {@code TargetInfo} implementations). These may(?) + * represent any target displayed somewhere other than the Direct Share UI. + */ + default boolean isDisplayResolveInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code MultiDisplayResolveInfo}. These + * objects were historically documented as representing "a 'stack' of chooser targets for + * various activities within the same component." For historical reasons this currently can + * return true only if {@link #isDisplayResolveInfo()} returns true (because the legacy classes + * shared an inheritance relationship), but new code should avoid relying on that relationship + * since these APIs are "in transition." + */ + default boolean isMultiDisplayResolveInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code SelectableTargetInfo}. Note that this + * is defined for legacy compatibility and may not conform to other notions of a "selectable" + * target. For historical reasons, this method and {@link #isNotSelectableTargetInfo()} only + * partition the {@code TargetInfo} instances for which {@link #isChooserTargetInfo()} returns + * true; otherwise both methods return false. + * TODO: define selectability for targets not historically from {@code ChooserTargetInfo}, + * then attempt to replace this with a new method like {@code TargetInfo#isSelectable()} that + * actually partitions all target types (after updating client usage as needed). + */ + default boolean isSelectableTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code NotSelectableTargetInfo} (i.e., a + * target where {@link #isChooserTargetInfo()} is true but {@link #isSelectableTargetInfo()} is + * false). For more information on how this divides the space of targets, see the Javadoc for + * {@link #isSelectableTargetInfo()}. + */ + default boolean isNotSelectableTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code ChooserActivity#EmptyTargetInfo}. Note + * that this is defined for legacy compatibility and may not conform to other notions of an + * "empty" target. + */ + default boolean isEmptyTargetInfo() { + return false; + } + + /** + * @return true if this target represents a legacy {@code ChooserActivity#PlaceHolderTargetInfo} + * (defined only for compatibility with historic use in {@link ChooserListAdapter}). For + * historic reasons (owing to a legacy subclass relationship) this can return true only if + * {@link #isNotSelectableTargetInfo()} also returns true. + */ + default boolean isPlaceHolderTargetInfo() { + return false; + } + + /** + * @return true if this target should be logged with the "direct_share" metrics category in + * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy + * compatibility and is not likely to be a good indicator of whether this is actually a + * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder" + * targets). + */ + default boolean isInDirectShareMetricsCategory() { + return isChooserTargetInfo(); + } + /** * Fix the URIs in {@code intent} if cross-profile sharing is required. This should be called * before launching the intent as another user. -- cgit v1.2.3-59-g8ed1b From 020daadc74edb11a2c2cff867aad7e9f6ee0885f Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 13 Oct 2022 20:49:40 +0000 Subject: Remove obsolete ENABLE_TABBED_VIEW feature flag. No plans to roll back that feature, no need to test it being disabled. Also remove a few unused constants. Bug: N/A Test: atest UnbundledChooserActivityTest Change-Id: I297c465d4850d7bd77e8fef9316f585d20ba27e2 --- .../android/intentresolver/ResolverActivity.java | 7 +- .../UnbundledChooserActivityTest.java | 109 --------------------- 2 files changed, 1 insertion(+), 115 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 26f3b535..1d57b9f2 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -163,17 +163,12 @@ public class ResolverActivity extends FragmentActivity implements /** See {@link #setRetainInOnStop}. */ private boolean mRetainInOnStop; - private static final String EXTRA_SHOW_FRAGMENT_ARGS = ":settings:show_fragment_args"; - private static final String EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key"; - private static final String OPEN_LINKS_COMPONENT_KEY = "app_link_state"; protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ private boolean mWorkProfileHasBeenEnabled = false; - @VisibleForTesting - public static boolean ENABLE_TABBED_VIEW = true; private static final String TAB_TAG_PERSONAL = "personal"; private static final String TAB_TAG_WORK = "work"; @@ -591,7 +586,7 @@ public class ResolverActivity extends FragmentActivity implements } protected boolean shouldShowTabs() { - return hasWorkProfile() && ENABLE_TABBED_VIEW; + return hasWorkProfile(); } protected void onProfileClick(View v) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 9ac815f6..de4a80bf 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -40,7 +40,6 @@ import static com.google.common.truth.Truth.assertThat; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; -import static junit.framework.Assert.assertTrue; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; @@ -639,8 +638,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void hasOtherProfileOneOption() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(4); @@ -676,9 +673,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3); @@ -716,9 +710,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void hasLastChosenActivityAndOtherProfile() throws Exception { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; - Intent sendIntent = createSendTextIntent(); List resolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3); @@ -1784,8 +1775,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); markWorkProfileUserAvailable(); @@ -1798,8 +1787,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -1811,8 +1798,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_eachTabUsesExpectedAdapter() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; int personalProfileTargets = 3; int otherProfileTargets = 1; List personalResolvedComponentInfos = @@ -1839,8 +1824,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -1862,8 +1845,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -1898,8 +1879,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -1924,7 +1903,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workProfileDisabled_emptyStateShown() { - // enable the work tab feature flag markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -1936,7 +1914,6 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - ResolverActivity.ENABLE_TABBED_VIEW = true; mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); onView(withId(com.android.internal.R.id.contentPanel)) @@ -1950,8 +1927,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -1975,8 +1950,6 @@ public class UnbundledChooserActivityTest { @Ignore // b/220067877 @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2001,8 +1974,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2335,8 +2306,6 @@ public class UnbundledChooserActivityTest { @Test @Ignore("b/222124533") public void testSwitchProfileLogging() throws InterruptedException { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -2414,70 +2383,8 @@ public class UnbundledChooserActivityTest { assertThat(logger.numCalls(), is(8)); } - @Test - public void testAutolaunch_singleTarget_wifthWorkProfileAndTabbedViewOff_noAutolaunch() { - ResolverActivity.ENABLE_TABBED_VIEW = false; - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - waitForIdle(); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertTrue(chosen[0] == null); - } - - @Test - public void testAutolaunch_singleTarget_noWorkProfile_autolaunch() { - ResolverActivity.ENABLE_TABBED_VIEW = false; - List personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - waitForIdle(); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(chosen[0], is(personalResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - @Test public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -2539,8 +2446,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 1; List personalResolvedComponentInfos = @@ -2571,8 +2476,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -2607,8 +2510,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTest(3); @@ -2675,8 +2576,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2707,8 +2606,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2739,8 +2636,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workUserNotRunning_workTargetsShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2764,8 +2659,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -2796,8 +2689,6 @@ public class UnbundledChooserActivityTest { @Test public void testWorkTab_workUserLocked_workTargetsShown() { - // enable the work tab feature flag - ResolverActivity.ENABLE_TABBED_VIEW = true; markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); -- cgit v1.2.3-59-g8ed1b From 61a786f29bc6112c125d79a0bda8845b2049ee80 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 11 Oct 2022 12:07:49 -0700 Subject: Load direct-share icons asynchronously Adopt ag/19920873 for the unbounded chooser. Test: manual testing Test: atest IntentResolverUnitTests:ChooserListAdapterTest Bug: 215699869 Change-Id: I7c06cda250f709b62abeeda83a21c1b3f3976a9b --- .../android/intentresolver/ChooserActivity.java | 8 ++ .../android/intentresolver/ChooserListAdapter.java | 67 +++++---- .../intentresolver/ResolverListAdapter.java | 3 +- .../chooser/SelectableTargetInfo.java | 16 ++- .../android/intentresolver/chooser/TargetInfo.java | 4 + java/tests/Android.bp | 1 + .../intentresolver/ChooserListAdapterTest.kt | 157 +++++++++++++++++++++ .../UnbundledChooserActivityTest.java | 10 +- 8 files changed, 223 insertions(+), 43 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 5f81b016..75c141e7 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2457,6 +2457,10 @@ public class ChooserActivity extends ResolverActivity implements avd.start(); // Start animation after generation return avd; } + + public boolean hasDisplayIcon() { + return true; + } } protected static final class EmptyTargetInfo extends NotSelectableTargetInfo { @@ -2470,6 +2474,10 @@ public class ChooserActivity extends ResolverActivity implements public Drawable getDisplayIcon(Context context) { return null; } + + public boolean hasDisplayIcon() { + return false; + } } private void handleScroll(View view, int x, int y, int oldx, int oldy) { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 0a8b3890..ad902049 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -98,8 +98,6 @@ public class ChooserListAdapter extends ResolverListAdapter { private AppPredictor mAppPredictor; private AppPredictor.Callback mAppPredictorCallback; - private LoadDirectShareIconTaskProvider mTestLoadDirectShareTaskProvider; - private final ShortcutSelectionLogic mShortcutSelectionLogic; // For pinned direct share labels, if the text spans multiple lines, the TextView will consume @@ -177,7 +175,9 @@ public class ChooserListAdapter extends ResolverListAdapter { final ComponentName cn = ii.getComponent(); if (cn != null) { try { - ai = packageManager.getActivityInfo(ii.getComponent(), 0); + ai = packageManager.getActivityInfo( + ii.getComponent(), + PackageManager.ComponentInfoFlags.of(PackageManager.GET_META_DATA)); ri = new ResolveInfo(); ri.activityInfo = ai; } catch (PackageManager.NameNotFoundException ignored) { @@ -187,7 +187,9 @@ public class ChooserListAdapter extends ResolverListAdapter { if (ai == null) { // Because of AIDL bug, resolveActivity can't accept subclasses of Intent. final Intent rii = (ii.getClass() == Intent.class) ? ii : new Intent(ii); - ri = packageManager.resolveActivity(rii, PackageManager.MATCH_DEFAULT_ONLY); + ri = packageManager.resolveActivity( + rii, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); ai = ri != null ? ri.activityInfo : null; } if (ai == null) { @@ -243,7 +245,6 @@ public class ChooserListAdapter extends ResolverListAdapter { mListViewDataChanged = false; } - private void createPlaceHolders() { mServiceTargets.clear(); for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { @@ -256,8 +257,9 @@ public class ChooserListAdapter extends ResolverListAdapter { return mInflater.inflate(R.layout.resolve_grid_item, parent, false); } + @VisibleForTesting @Override - protected void onBindView(View view, TargetInfo info, int position) { + public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); if (info == null) { @@ -270,12 +272,16 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.bindIcon(info); if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout - DisplayResolveInfo rInfo = ((SelectableTargetInfo) info).getDisplayResolveInfo(); + SelectableTargetInfo sti = (SelectableTargetInfo) info; + DisplayResolveInfo rInfo = sti.getDisplayResolveInfo(); CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; CharSequence extendedInfo = info.getExtendedInfo(); String contentDescription = String.join(" ", info.getDisplayLabel(), extendedInfo != null ? extendedInfo : "", appName); holder.updateContentDescription(contentDescription); + if (!sti.hasDisplayIcon()) { + loadDirectShareIcon(sti); + } } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { @@ -320,6 +326,20 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + private void loadDirectShareIcon(SelectableTargetInfo info) { + LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); + if (task == null) { + task = createLoadDirectShareIconTask(info); + mIconLoaders.put(info, task); + task.loadIcon(); + } + } + + @VisibleForTesting + protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) { + return new LoadDirectShareIconTask(info); + } + void updateAlphabeticalList() { new AsyncTask>() { @Override @@ -660,36 +680,25 @@ public class ChooserListAdapter extends ResolverListAdapter { * Loads direct share targets icons. */ @VisibleForTesting - public class LoadDirectShareIconTask extends AsyncTask { + public class LoadDirectShareIconTask extends AsyncTask { private final SelectableTargetInfo mTargetInfo; - private ViewHolder mViewHolder; private LoadDirectShareIconTask(SelectableTargetInfo targetInfo) { mTargetInfo = targetInfo; } @Override - protected Void doInBackground(Void... voids) { - mTargetInfo.loadIcon(); - return null; + protected Boolean doInBackground(Void... voids) { + return mTargetInfo.loadIcon(); } @Override - protected void onPostExecute(Void arg) { - if (mViewHolder != null) { - mViewHolder.bindIcon(mTargetInfo); + protected void onPostExecute(Boolean isLoaded) { + if (isLoaded) { notifyDataSetChanged(); } } - /** - * Specifies a view holder that will be updated when the task is completed. - */ - public void setViewHolder(ViewHolder viewHolder) { - mViewHolder = viewHolder; - mViewHolder.bindIcon(mTargetInfo); - } - /** * An alias for execute to use with unit tests. */ @@ -697,16 +706,4 @@ public class ChooserListAdapter extends ResolverListAdapter { execute(); } } - - /** - * An interface for the unit tests to override icon loading task creation - */ - @VisibleForTesting - public interface LoadDirectShareIconTaskProvider { - /** - * Provides an instance of the task. - * @return - */ - LoadDirectShareIconTask get(); - } } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 3c8eae3a..0e58aff8 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -870,7 +870,8 @@ public class ResolverListAdapter extends BaseAdapter { } /** - * A view holder. + * A view holder keeps a reference to a list view and provides functionality for managing its + * state. */ @VisibleForTesting public static class ViewHolder { diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 0f48cfd1..758e77ca 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -157,20 +157,25 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { /** * Load display icon, if needed. */ - public void loadIcon() { + public boolean loadIcon() { ShortcutInfo shortcutInfo; Drawable icon; synchronized (this) { shortcutInfo = mShortcutInfo; icon = mDisplayIcon; } - if (icon == null && shortcutInfo != null) { + boolean shouldLoadIcon = (icon == null) && (shortcutInfo != null); + if (shouldLoadIcon) { icon = getChooserTargetIconDrawable(mChooserTarget, shortcutInfo); + if (icon == null) { + return false; + } synchronized (this) { mDisplayIcon = icon; mShortcutInfo = null; } } + return shouldLoadIcon; } private Drawable getChooserTargetIconDrawable(ChooserTarget target, @@ -307,6 +312,13 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mDisplayIcon; } + /** + * @return true if display icon is available + */ + public synchronized boolean hasDisplayIcon() { + return mDisplayIcon != null; + } + public ChooserTarget getChooserTarget() { return mChooserTarget; } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 502a3342..220b6467 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -111,6 +111,10 @@ public interface TargetInfo { */ Drawable getDisplayIcon(Context context); + /** + * @return true if display icon is available. + */ + boolean hasDisplayIcon(); /** * Clone this target with the given fill-in information. */ diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 9598613c..2913d128 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -20,6 +20,7 @@ android_test { static_libs: [ "IntentResolver-core", "androidx.test.rules", + "androidx.test.ext.junit", "mockito-target-minus-junit4", "androidx.test.espresso.core", "truth-prebuilt", diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt new file mode 100644 index 00000000..87e93705 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.content.ComponentName +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.os.Bundle +import android.service.chooser.ChooserTarget +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask +import com.android.intentresolver.chooser.SelectableTargetInfo +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator +import com.android.internal.R +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class ChooserListAdapterTest { + private val packageManager = mock { + whenever( + resolveActivity(any(), any()) + ).thenReturn(mock()) + } + private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val resolverListController = mock() + private val chooserListCommunicator = mock { + whenever(maxRankedTargets).thenReturn(0) + } + private val selectableTargetInfoCommunicator = + mock { + whenever(targetIntent).thenReturn(mock()) + } + private val chooserActivityLogger = mock() + + private fun createChooserListAdapter( + taskProvider: (SelectableTargetInfo?) -> LoadDirectShareIconTask + ) = object : ChooserListAdapter( + context, + emptyList(), + emptyArray(), + emptyList(), + false, + resolverListController, + chooserListCommunicator, + selectableTargetInfoCommunicator, + packageManager, + chooserActivityLogger, + ) { + override fun createLoadDirectShareIconTask( + info: SelectableTargetInfo? + ): LoadDirectShareIconTask = taskProvider(info) + } + + @Before + fun setup() { + // ChooserListAdapter reads DeviceConfig and needs a permission for that. + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") + } + + @Test + fun testDirectShareTargetLoadingIconIsStarted() { + val view = createView() + val viewHolder = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolder + val targetInfo = createSelectableTargetInfo() + val iconTask = mock() + val testSubject = createChooserListAdapter { iconTask } + testSubject.onBindView(view, targetInfo, 0) + + verify(iconTask, times(1)).loadIcon() + } + + @Test + fun testOnlyOneTaskPerTarget() { + val view = createView() + val viewHolderOne = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderOne + val targetInfo = createSelectableTargetInfo() + val iconTaskOne = mock() + val testTaskProvider = mock<() -> LoadDirectShareIconTask> { + whenever(invoke()).thenReturn(iconTaskOne) + } + val testSubject = createChooserListAdapter { testTaskProvider.invoke() } + testSubject.onBindView(view, targetInfo, 0) + + val viewHolderTwo = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderTwo + whenever(testTaskProvider()).thenReturn(mock()) + + testSubject.onBindView(view, targetInfo, 0) + + verify(iconTaskOne, times(1)).loadIcon() + verify(testTaskProvider, times(1)).invoke() + } + + private fun createSelectableTargetInfo(): SelectableTargetInfo = + SelectableTargetInfo( + context, + null, + createChooserTarget(), + 1f, + selectableTargetInfoCommunicator, + null + ) + + private fun createChooserTarget(): ChooserTarget = + ChooserTarget( + "Title", + null, + 1f, + ComponentName("package", "package.Class"), + Bundle() + ) + + private fun createView(): View { + val view = FrameLayout(context) + TextView(context).apply { + id = R.id.text1 + view.addView(this) + } + TextView(context).apply { + id = R.id.text2 + view.addView(this) + } + ImageView(context).apply { + id = R.id.icon + view.addView(this) + } + return view + } +} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 9ac815f6..752f52fe 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -2525,7 +2525,7 @@ public class UnbundledChooserActivityTest { when( ChooserActivityOverrideData .getInstance().packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(ri); waitForIdle(); @@ -2558,7 +2558,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); waitForIdle(); @@ -2591,7 +2591,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); mActivityRule.launchActivity(chooserIntent); @@ -2625,7 +2625,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(createFakeResolveInfo()); mActivityRule.launchActivity(chooserIntent); @@ -2660,7 +2660,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData .getInstance() .packageManager - .resolveActivity(any(Intent.class), anyInt())) + .resolveActivity(any(Intent.class), any())) .thenReturn(ri); waitForIdle(); -- cgit v1.2.3-59-g8ed1b From ab7f28ea95dc438a02fee273904f2f289aee71dd Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 13 Oct 2022 12:20:11 -0400 Subject: Encapsulate NotSelectableTargetInfo implementation This replaces ChooserActivity's two inner-class implementations of the NotSelectableTargetInfo API with new factory methods (provided statically by NotSelectableTargetInfo) that return anonymous-class implementations with all the same overrides. This effectively removes the two implementations from ChooserActivity (where they were "just more clutter") and ensures that clients can't rely on old-style dynamic type checks to dispatch type-specific logic involving these targets. This is a small step towards generally "flattening" the TargetInfo hierarchy as described at go/chooser-targetinfo-cleanup. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ib7125299f7f63cffb0134986d7522f338998bb19 --- .../android/intentresolver/ChooserActivity.java | 37 ---------------- .../android/intentresolver/ChooserListAdapter.java | 7 +-- .../chooser/NotSelectableTargetInfo.java | 50 ++++++++++++++++++++++ .../intentresolver/ChooserWrapperActivity.java | 3 +- 4 files changed, 56 insertions(+), 41 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 75c141e7..744c9323 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -65,7 +65,6 @@ import android.graphics.Color; import android.graphics.Insets; import android.graphics.Paint; import android.graphics.Path; -import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.metrics.LogMaker; import android.net.Uri; @@ -120,7 +119,6 @@ import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; @@ -2445,41 +2443,6 @@ public class ChooserActivity extends ResolverActivity implements return null; } - static final class PlaceHolderTargetInfo extends NotSelectableTargetInfo { - @Override - public boolean isPlaceHolderTargetInfo() { - return true; - } - - public Drawable getDisplayIcon(Context context) { - AnimatedVectorDrawable avd = (AnimatedVectorDrawable) - context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); - avd.start(); // Start animation after generation - return avd; - } - - public boolean hasDisplayIcon() { - return true; - } - } - - protected static final class EmptyTargetInfo extends NotSelectableTargetInfo { - public EmptyTargetInfo() {} - - @Override - public boolean isEmptyTargetInfo() { - return true; - } - - public Drawable getDisplayIcon(Context context) { - return null; - } - - public boolean hasDisplayIcon() { - return false; - } - } - private void handleScroll(View view, int x, int y, int oldx, int oldy) { if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index ad902049..3c16a85d 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -47,6 +47,7 @@ import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; @@ -86,8 +87,8 @@ public class ChooserListAdapter extends ResolverListAdapter { private final Map mIconLoaders = new HashMap<>(); // Reserve spots for incoming direct share targets by adding placeholders - private ChooserTargetInfo - mPlaceHolderTargetInfo = new ChooserActivity.PlaceHolderTargetInfo(); + private ChooserTargetInfo mPlaceHolderTargetInfo = + NotSelectableTargetInfo.newPlaceHolderTargetInfo(); private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); @@ -600,7 +601,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void completeServiceTargetLoading() { mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo()); if (mServiceTargets.isEmpty()) { - mServiceTargets.add(new ChooserActivity.EmptyTargetInfo()); + mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo()); mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); } notifyDataSetChanged(); diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index db03b785..3a488e32 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -18,12 +18,16 @@ package com.android.intentresolver.chooser; import android.app.Activity; import android.content.ComponentName; +import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; import android.service.chooser.ChooserTarget; +import com.android.intentresolver.R; import com.android.intentresolver.ResolverActivity; import java.util.List; @@ -33,6 +37,52 @@ import java.util.List; * placeholders for the system while information is loading in an async manner. */ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { + /** Create a non-selectable {@link TargetInfo} with no content. */ + public static ChooserTargetInfo newEmptyTargetInfo() { + return new NotSelectableTargetInfo() { + @Override + public boolean isEmptyTargetInfo() { + return true; + } + + @Override + public Drawable getDisplayIcon(Context context) { + return null; + } + + @Override + public boolean hasDisplayIcon() { + return false; + } + }; + } + + /** + * Create a non-selectable {@link TargetInfo} with placeholder content to be displayed + * unless/until it can be replaced by the result of a pending asynchronous load. + */ + public static ChooserTargetInfo newPlaceHolderTargetInfo() { + return new NotSelectableTargetInfo() { + @Override + public boolean isPlaceHolderTargetInfo() { + return true; + } + + @Override + public Drawable getDisplayIcon(Context context) { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) + context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation. + return avd; + } + + @Override + public boolean hasDisplayIcon() { + return true; + } + }; + } + public final boolean isNotSelectableTargetInfo() { return true; } diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 0e9f010e..ac5d30f8 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -41,6 +41,7 @@ import com.android.intentresolver.IChooserWrapper; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -119,7 +120,7 @@ public class ChooserWrapperActivity @Override protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - return new ChooserWrapperActivity.EmptyTargetInfo(); + return NotSelectableTargetInfo.newEmptyTargetInfo(); } @Override -- cgit v1.2.3-59-g8ed1b From 77fbad4eae8149dcdb2c74206e05cc8089143f19 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 14 Oct 2022 17:53:26 -0700 Subject: Some static-analysis-based code cleanup Follow the IDE suggestions and remove some unreachable condition branches in the code. Remove unused interface. Test: atest IntentResolverUnitTests Change-Id: I5dc1296963999414f8c6143641e8cea60b60674b --- .../android/intentresolver/ChooserActivity.java | 37 ++++++++-------------- .../intentresolver/ShortcutSelectionLogic.java | 5 --- 2 files changed, 13 insertions(+), 29 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 75c141e7..2339b3a9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -554,32 +554,23 @@ public class ChooserActivity extends ResolverActivity implements super.onCreate(null); return; } - Intent target = (Intent) targetParcelable; - if (target != null) { - modifyTargetIntent(target); - } + final Intent target = (Intent) targetParcelable; + modifyTargetIntent(target); Parcelable[] targetsParcelable = intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS); if (targetsParcelable != null) { - final boolean offset = target == null; - Intent[] additionalTargets = - new Intent[offset ? targetsParcelable.length - 1 : targetsParcelable.length]; + Intent[] additionalTargets = new Intent[targetsParcelable.length]; for (int i = 0; i < targetsParcelable.length; i++) { if (!(targetsParcelable[i] instanceof Intent)) { - Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i + " is not an Intent: " - + targetsParcelable[i]); + Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i + + " is not an Intent: " + targetsParcelable[i]); finish(); super.onCreate(null); return; } final Intent additionalTarget = (Intent) targetsParcelable[i]; - if (i == 0 && target == null) { - target = additionalTarget; - modifyTargetIntent(target); - } else { - additionalTargets[offset ? i - 1 : i] = additionalTarget; - modifyTargetIntent(additionalTarget); - } + additionalTargets[i] = additionalTarget; + modifyTargetIntent(additionalTarget); } setAdditionalTargets(additionalTargets); } @@ -588,14 +579,12 @@ public class ChooserActivity extends ResolverActivity implements // Do not allow the title to be changed when sharing content CharSequence title = null; - if (target != null) { - if (!isSendAction(target)) { - title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE); - } else { - Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" - + " preview title by using EXTRA_TITLE property of the wrapped" - + " EXTRA_INTENT."); - } + if (!isSendAction(target)) { + title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE); + } else { + Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" + + " preview title by using EXTRA_TITLE property of the wrapped" + + " EXTRA_INTENT."); } int defaultTitleRes = 0; diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index e9470231..8ec227fc 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -17,7 +17,6 @@ package com.android.intentresolver; import android.annotation.Nullable; -import android.content.ComponentName; import android.content.Context; import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; @@ -159,8 +158,4 @@ class ShortcutSelectionLogic { return false; } - - public interface ScoreProvider { - float getScore(ComponentName componentName); - } } -- cgit v1.2.3-59-g8ed1b From d2ea5e02215608c95b0af449b1447a5e523bd9da Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 13 Oct 2022 12:41:41 -0400 Subject: Unify APIs descending from ChooserTargetInfo. Specifically, this pulls up the two new methods introduced by SelectableTargetInfo (since we can easily specify a default behavior for other kinds of ChooserTargetInfo) and adds @Override annotations to show that all the remaining methods are now just overrides that conform to the same overall interface. Finally, any clients that "access" (but don't "create") instances of SelectableTargetInfo are now able to perform all their same operations against the parent ChooserTargetInfo, so all these call sites have been generalized to remove the dependency on the narrower SelectableTargetInfo type. As per go/chooser-targetinfo-cleanup this is one step in a broader refactoring to simplify & ideally "flatten" this class hierarchy. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Id6c4f8e904d4390ed86cbc8c4966babd364b8209 --- .../android/intentresolver/ChooserActivity.java | 7 +++---- .../android/intentresolver/ChooserListAdapter.java | 22 ++++++++++------------ .../intentresolver/chooser/ChooserTargetInfo.java | 20 ++++++++++++++++++++ .../chooser/SelectableTargetInfo.java | 6 ++++++ .../intentresolver/ChooserListAdapterTest.kt | 7 ++++--- 5 files changed, 43 insertions(+), 19 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 744c9323..0258a1ee 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -119,7 +119,6 @@ import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -1723,7 +1722,7 @@ public class ChooserActivity extends ResolverActivity implements Bundle bundle = new Bundle(); if (targetInfo.isSelectableTargetInfo()) { - SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; + ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; if (selectableTargetInfo.getDisplayResolveInfo() == null || selectableTargetInfo.getChooserTarget() == null) { Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null"); @@ -1849,7 +1848,7 @@ public class ChooserActivity extends ResolverActivity implements target.getComponentName().getPackageName() + target.getTitle().toString(), mMaxHashSaltDays); - SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; + ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; directTargetAlsoRanked = getRankedPosition(selectableTargetInfo); if (mCallerChooserTargets != null) { @@ -1917,7 +1916,7 @@ public class ChooserActivity extends ResolverActivity implements } } - private int getRankedPosition(SelectableTargetInfo targetInfo) { + private int getRankedPosition(ChooserTargetInfo targetInfo) { String targetPackageName = targetInfo.getChooserTarget().getComponentName().getPackageName(); ChooserListAdapter currentListAdapter = diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 3c16a85d..72382663 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -48,7 +48,7 @@ import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -80,8 +80,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; private final ChooserListCommunicator mChooserListCommunicator; - private final SelectableTargetInfo.SelectableTargetInfoCommunicator - mSelectableTargetInfoCommunicator; + private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; private final ChooserActivityLogger mChooserActivityLogger; private final Map mIconLoaders = new HashMap<>(); @@ -140,7 +139,7 @@ public class ChooserListAdapter extends ResolverListAdapter { boolean filterLastUsed, ResolverListController resolverListController, ChooserListCommunicator chooserListCommunicator, - SelectableTargetInfo.SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, + SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, PackageManager packageManager, ChooserActivityLogger chooserActivityLogger) { // Don't send the initial intents through the shared ResolverActivity path, @@ -273,15 +272,14 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.bindIcon(info); if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout - SelectableTargetInfo sti = (SelectableTargetInfo) info; - DisplayResolveInfo rInfo = sti.getDisplayResolveInfo(); + DisplayResolveInfo rInfo = ((ChooserTargetInfo) info).getDisplayResolveInfo(); CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; CharSequence extendedInfo = info.getExtendedInfo(); String contentDescription = String.join(" ", info.getDisplayLabel(), extendedInfo != null ? extendedInfo : "", appName); holder.updateContentDescription(contentDescription); - if (!sti.hasDisplayIcon()) { - loadDirectShareIcon(sti); + if (!info.hasDisplayIcon()) { + loadDirectShareIcon((ChooserTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; @@ -327,7 +325,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - private void loadDirectShareIcon(SelectableTargetInfo info) { + private void loadDirectShareIcon(ChooserTargetInfo info) { LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); if (task == null) { task = createLoadDirectShareIconTask(info); @@ -337,7 +335,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } @VisibleForTesting - protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) { + protected LoadDirectShareIconTask createLoadDirectShareIconTask(ChooserTargetInfo info) { return new LoadDirectShareIconTask(info); } @@ -682,9 +680,9 @@ public class ChooserListAdapter extends ResolverListAdapter { */ @VisibleForTesting public class LoadDirectShareIconTask extends AsyncTask { - private final SelectableTargetInfo mTargetInfo; + private final ChooserTargetInfo mTargetInfo; - private LoadDirectShareIconTask(SelectableTargetInfo targetInfo) { + private LoadDirectShareIconTask(ChooserTargetInfo targetInfo) { mTargetInfo = targetInfo; } diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 77c30102..a1ba87f8 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -53,6 +53,26 @@ public abstract class ChooserTargetInfo implements TargetInfo { return true; } + /** + * Attempt to load the display icon, if we have the info for one but it hasn't been loaded yet. + * @return true if an icon may have been loaded as the result of this operation, potentially + * prompting a UI refresh. If this returns false, clients can safely assume there was no change. + */ + public boolean loadIcon() { + return false; + } + + /** + * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available. + * TODO: determine the meaning of a TargetInfo (ChooserTargetInfo) embedding another kind of + * TargetInfo (DisplayResolveInfo) in this way, and - at least - improve this documentation; + * OTOH this probably indicates an opportunity to simplify or better separate these APIs. + */ + @Nullable + public DisplayResolveInfo getDisplayResolveInfo() { + return null; + } + /** * Do not label as 'equals', since this doesn't quite work * as intended with java 8. diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 758e77ca..b9df02c8 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -145,10 +145,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return sb.toString(); } + @Override public boolean isSuspended() { return mIsSuspended; } + @Override @Nullable public DisplayResolveInfo getDisplayResolveInfo() { return mSourceInfo; @@ -157,6 +159,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { /** * Load display icon, if needed. */ + @Override public boolean loadIcon() { ShortcutInfo shortcutInfo; Drawable icon; @@ -215,6 +218,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return new BitmapDrawable(mContext.getResources(), directShareBadgedIcon); } + @Override public float getModifiedScore() { return mModifiedScore; } @@ -315,10 +319,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { /** * @return true if display icon is available */ + @Override public synchronized boolean hasDisplayIcon() { return mDisplayIcon != null; } + @Override public ChooserTarget getChooserTarget() { return mChooserTarget; } diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 87e93705..116d49bf 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -28,6 +28,7 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask +import com.android.intentresolver.chooser.ChooserTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator import com.android.internal.R @@ -56,7 +57,7 @@ class ChooserListAdapterTest { private val chooserActivityLogger = mock() private fun createChooserListAdapter( - taskProvider: (SelectableTargetInfo?) -> LoadDirectShareIconTask + taskProvider: (ChooserTargetInfo?) -> LoadDirectShareIconTask ) = object : ChooserListAdapter( context, emptyList(), @@ -70,7 +71,7 @@ class ChooserListAdapterTest { chooserActivityLogger, ) { override fun createLoadDirectShareIconTask( - info: SelectableTargetInfo? + info: ChooserTargetInfo? ): LoadDirectShareIconTask = taskProvider(info) } @@ -119,7 +120,7 @@ class ChooserListAdapterTest { verify(testTaskProvider, times(1)).invoke() } - private fun createSelectableTargetInfo(): SelectableTargetInfo = + private fun createSelectableTargetInfo(): ChooserTargetInfo = SelectableTargetInfo( context, null, -- cgit v1.2.3-59-g8ed1b From 437e442cf43089e183181cd27d56258ef33ec966 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 12 Oct 2022 12:25:28 -0400 Subject: Inline & remove extraneous helper method. This is trivially a "pure refactoring" (after a fair amount of reasoning, to follow in this CL description). We have ongoing work both to unify the TargetInfo APIs (go/chooser-targetinfo-cleanup) and to reduce our internal reliance on the deprecated `ChooserTarget` API. This CL simplifies one usage site in advance of further cleanup work, while removing one of our `ChooserTarget`-based internal APIs. The old design was also unnecessarily flexible, requiring us to reason through runtime behavior to prove(*) the equivalence of this CL; that reasoning is much more apparent the way it's written now. * [Proof of equivalence] By inspection in `ChooserListAdapter`, the `ChooserTarget` was originally accessed by ``` public ChooserTarget getChooserTargetForValue(int value) { return mServiceTargets.get(value).getChooserTarget(); } ``` We assert that the call to `mServiceTargets.get(value)` will retrieve the same `ChooserTargetInfo` instance as already returned by `ChooserListAdapter.targetInfoForPosition(which, filtered)` (noting that `value` and `which` are aliases in all the relevant legacy code). The logic in `targetInfoForPosition()` returns the same `mServiceTargets.get(position)` (with `position` another alias of `value`/`which`) for any non-negative `position`, up to some `serviceTargetCount` which depends on the value of `filtered` (a parameter that seems a little confusing & not well-documented, but we'll leave that detail out-of-scope for now). This is somewhat concerning because our `filtered` value comes from the argument to `ChooserActivity.startSelected()`, and it's passed on to `ChooserListAdapter.targetInfoForPosition()` but *not* to the helper `getChooserTargetForValue()` -- suggesting a possible bug if `targetInfoForPosition()` ever in fact returns a different value depending on the choice of `filtered` parameter, since then the two getters would return info about different targets, and our log event would get misattributed. (The ability to return a `ChooserTarget` that isn't composed-into the `TargetInfo` at that position is the aforementioned "extra flexibility" that can do us no good.) The proposed change only applies in the case when `ChooserListAdapter.getPositionTargetType(which)` returns `TARGET_SERVICE`, implying `which < getServiceTargetCount()`; this is the same limit selected for `targetInfoForPosition()` when `filtered` is true, so in that event the behavior will be the same. If `filtered` is false, `targetInfoForPosition()` instead compares to a limit given by `getSelectableServiceTargetCount()`, the number of elements in `mServiceTargets` that are instances of `SelectableTargetInfo`. (We can already see this *should* be safe, since the pre-existing code in `ChooserActivity.startSelected()` already included an unchecked downcast to `SelectableTargetInfo`, and we would've observed a "noisier" crash if there was a problem with that assumption; note no other branch of `targetInfoForPosition()` returns objects of any subtype of `SelectableTargetInfo`, so these instances must have been retrieved directly from `mServiceTargets`. It may also be reasonable to assume that a target we examine in the `startSelected()` method is in fact "Selectable", which in fact it already checks -- *as long as the target is a subclass of `ChooserTargetInfo`*, and thus partitioned into being either a `Selectable-` or `NotSelectableTargetInfo`). For the hypothetical bug to occur, we would need to be requesting the `TargetInfo` for a `position` greater than the value returned from `getSelectableServiceTargetCount()` (so that `targetInfoForPosition()` goes on to take the target from somewhere other than the corresponding position in the `mServiceTargets` list), but less than the value returned from `getServiceTargetCount()` (so that the target is still classified as a `TARGET_SERVICE`). Thus we're concerned with cases when the "selectable" target count might be lower than the total target count (even after accounting for any other conditions in `getServiceTargetCount()`). Inspecting all the insertions to `ChooserListAdapter.mServiceTargets` we note that each site takes care to honor the limit set by `mChooserListCommunicator.getMaxRankedTargets()`; that is, `mServiceTargets.size()` should never exceed this limit, and the `Math.min` expression in `getServiceTargetCount()` should always be a no-op. Thus the result of `getSelectableServiceTargetCount()` can only be lower *because* it filtered out exactly that many service targets that didn't inherit from `SelectableTargetInfo`. OTOH we can see from `ChooserListAdapter.getSurfacedTargetInfo()` that the "selectable" targets are represented as a *prefix* of the `mServiceTargets` list (i.e., any filtered-out "non-selectable" items can only occur after all others), and thus any problematic `position` we might query beyond the limit of `getSelectableServiceTargetCount()` would have to be a non-selectable item (by inspection, one of `ChooserActivity`'s two inner-class implementations of `NotSelectableTargetInfo`). But any item of these non-selectable types would be discarded by the guard clause at the top of `ChooserActivity.startSelected()`, so any item that we went on to process for logging must be from one of the earlier indices directly into `mServiceTargets`, QED. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ia6435345786d43e26be6384c4de941fd86c6079d --- java/src/com/android/intentresolver/ChooserActivity.java | 4 ++-- java/src/com/android/intentresolver/ChooserListAdapter.java | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 1e745d06..9d4515fa 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1830,14 +1830,14 @@ public class ChooserActivity extends ResolverActivity implements cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; // Log the package name + target name to answer the question if most users // share to mostly the same person or to a bunch of different people. - ChooserTarget target = currentListAdapter.getChooserTargetForValue(value); + ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; + ChooserTarget target = selectableTargetInfo.getChooserTarget(); directTargetHashed = HashedStringCache.getInstance().hashString( this, TAG, target.getComponentName().getPackageName() + target.getTitle().toString(), mMaxHashSaltDays); - ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; directTargetAlsoRanked = getRankedPosition(selectableTargetInfo); if (mCallerChooserTargets != null) { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 72382663..98e3dcdb 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -605,10 +605,6 @@ public class ChooserListAdapter extends ResolverListAdapter { notifyDataSetChanged(); } - public ChooserTarget getChooserTargetForValue(int value) { - return mServiceTargets.get(value).getChooserTarget(); - } - protected boolean alwaysShowSubLabel() { // Always show a subLabel for visual consistency across list items. Show an empty // subLabel if the subLabel is the same as the label -- cgit v1.2.3-59-g8ed1b From fb433f9d327ee66e880d7b93bca74c9fd81f0ad2 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 13 Oct 2022 14:28:12 -0400 Subject: Pull ChooserTargetInfo API up to base TargetInfo. As in ag/20189669, this makes the concrete type into an implementation detail while allowing clients to operate in terms of the more general base interface (with an eye, long-term, towards potentially "flattening" this class hierarchy, per go/chooser-targetinfo-cleanup). After this CL, clients that only "access" but don't "create" target objects shouldn't need to know the concrete type of any instance on ChooserTargetInfo side of the tree (i.e., clients only potentially need to care about the runtime type of targets that descend from DisplayResolveInfo -- until that side too can be cleaned up in a future CL). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I47d54c64971e1c0ca2a4953d347eb4323a598f5c --- .../android/intentresolver/ChooserActivity.java | 31 ++++----- .../android/intentresolver/ChooserListAdapter.java | 21 +++--- .../intentresolver/ShortcutSelectionLogic.java | 12 ++-- .../intentresolver/chooser/ChooserTargetInfo.java | 50 +-------------- .../chooser/NotSelectableTargetInfo.java | 4 +- .../android/intentresolver/chooser/TargetInfo.java | 74 ++++++++++++++++++++++ .../intentresolver/ChooserListAdapterTest.kt | 7 +- .../intentresolver/ShortcutSelectionLogicTest.kt | 18 +++--- 8 files changed, 121 insertions(+), 96 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9d4515fa..63ab20cd 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -116,7 +116,6 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; import com.android.intentresolver.ResolverListAdapter.ViewHolder; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; @@ -1711,24 +1710,23 @@ public class ChooserActivity extends ResolverActivity implements Bundle bundle = new Bundle(); if (targetInfo.isSelectableTargetInfo()) { - ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; - if (selectableTargetInfo.getDisplayResolveInfo() == null - || selectableTargetInfo.getChooserTarget() == null) { + if (targetInfo.getDisplayResolveInfo() == null + || targetInfo.getChooserTarget() == null) { Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null"); return; } targetList = new ArrayList<>(); - targetList.add(selectableTargetInfo.getDisplayResolveInfo()); + targetList.add(targetInfo.getDisplayResolveInfo()); bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, - selectableTargetInfo.getChooserTarget().getIntentExtras().getString( + targetInfo.getChooserTarget().getIntentExtras().getString( Intent.EXTRA_SHORTCUT_ID)); bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, - selectableTargetInfo.isPinned()); + targetInfo.isPinned()); bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, getTargetIntentFilter()); - if (selectableTargetInfo.getDisplayLabel() != null) { + if (targetInfo.getDisplayLabel() != null) { bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, - selectableTargetInfo.getDisplayLabel().toString()); + targetInfo.getDisplayLabel().toString()); } } else if (targetInfo.isMultiDisplayResolveInfo()) { // For multiple targets, include info on all targets @@ -1830,15 +1828,14 @@ public class ChooserActivity extends ResolverActivity implements cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; // Log the package name + target name to answer the question if most users // share to mostly the same person or to a bunch of different people. - ChooserTargetInfo selectableTargetInfo = (ChooserTargetInfo) targetInfo; - ChooserTarget target = selectableTargetInfo.getChooserTarget(); + ChooserTarget target = targetInfo.getChooserTarget(); directTargetHashed = HashedStringCache.getInstance().hashString( this, TAG, target.getComponentName().getPackageName() + target.getTitle().toString(), mMaxHashSaltDays); - directTargetAlsoRanked = getRankedPosition(selectableTargetInfo); + directTargetAlsoRanked = getRankedPosition(targetInfo); if (mCallerChooserTargets != null) { numCallerProvided = mCallerChooserTargets.length; @@ -1847,7 +1844,7 @@ public class ChooserActivity extends ResolverActivity implements SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, value, - selectableTargetInfo.isPinned() + targetInfo.isPinned() ); break; case ChooserListAdapter.TARGET_CALLER: @@ -1905,7 +1902,7 @@ public class ChooserActivity extends ResolverActivity implements } } - private int getRankedPosition(ChooserTargetInfo targetInfo) { + private int getRankedPosition(TargetInfo targetInfo) { String targetPackageName = targetInfo.getChooserTarget().getComponentName().getPackageName(); ChooserListAdapter currentListAdapter = @@ -2158,9 +2155,9 @@ public class ChooserActivity extends ResolverActivity implements if (targetInfo.isChooserTargetInfo()) { return; } - List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); + List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); List targetIds = new ArrayList<>(); - for (ChooserTargetInfo chooserTargetInfo : surfacedTargetInfo) { + for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { ChooserTarget chooserTarget = chooserTargetInfo.getChooserTarget(); ComponentName componentName = chooserTarget.getComponentName(); if (mDirectShareShortcutInfoCache.containsKey(chooserTarget)) { @@ -2182,7 +2179,7 @@ public class ChooserActivity extends ResolverActivity implements if (!targetInfo.isChooserTargetInfo()) { return; } - ChooserTarget chooserTarget = ((ChooserTargetInfo) targetInfo).getChooserTarget(); + ChooserTarget chooserTarget = targetInfo.getChooserTarget(); AppTarget appTarget = null; if (mDirectShareAppTargetCache != null) { appTarget = mDirectShareAppTargetCache.get(chooserTarget); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 98e3dcdb..9c5c979c 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -44,7 +44,6 @@ import android.view.ViewGroup; import android.widget.TextView; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; @@ -86,9 +85,9 @@ public class ChooserListAdapter extends ResolverListAdapter { private final Map mIconLoaders = new HashMap<>(); // Reserve spots for incoming direct share targets by adding placeholders - private ChooserTargetInfo mPlaceHolderTargetInfo = + private TargetInfo mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(); - private final List mServiceTargets = new ArrayList<>(); + private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); private boolean mListViewDataChanged = false; @@ -272,14 +271,14 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.bindIcon(info); if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout - DisplayResolveInfo rInfo = ((ChooserTargetInfo) info).getDisplayResolveInfo(); + DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; CharSequence extendedInfo = info.getExtendedInfo(); String contentDescription = String.join(" ", info.getDisplayLabel(), extendedInfo != null ? extendedInfo : "", appName); holder.updateContentDescription(contentDescription); if (!info.hasDisplayIcon()) { - loadDirectShareIcon((ChooserTargetInfo) info); + loadDirectShareIcon(info); } } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; @@ -325,7 +324,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - private void loadDirectShareIcon(ChooserTargetInfo info) { + private void loadDirectShareIcon(TargetInfo info) { LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); if (task == null) { task = createLoadDirectShareIconTask(info); @@ -335,7 +334,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } @VisibleForTesting - protected LoadDirectShareIconTask createLoadDirectShareIconTask(ChooserTargetInfo info) { + protected LoadDirectShareIconTask createLoadDirectShareIconTask(TargetInfo info) { return new LoadDirectShareIconTask(info); } @@ -405,7 +404,7 @@ public class ChooserListAdapter extends ResolverListAdapter { */ public int getSelectableServiceTargetCount() { int count = 0; - for (ChooserTargetInfo info : mServiceTargets) { + for (TargetInfo info : mServiceTargets) { if (info.isSelectableTargetInfo()) { count++; } @@ -532,7 +531,7 @@ public class ChooserListAdapter extends ResolverListAdapter { /** * Fetch surfaced direct share target info */ - public List getSurfacedTargetInfo() { + public List getSurfacedTargetInfo() { int maxSurfacedTargets = mChooserListCommunicator.getMaxRankedTargets(); return mServiceTargets.subList(0, Math.min(maxSurfacedTargets, getSelectableServiceTargetCount())); @@ -676,9 +675,9 @@ public class ChooserListAdapter extends ResolverListAdapter { */ @VisibleForTesting public class LoadDirectShareIconTask extends AsyncTask { - private final ChooserTargetInfo mTargetInfo; + private final TargetInfo mTargetInfo; - private LoadDirectShareIconTask(ChooserTargetInfo targetInfo) { + private LoadDirectShareIconTask(TargetInfo targetInfo) { mTargetInfo = targetInfo; } diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 8ec227fc..07573d1b 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -22,10 +22,10 @@ import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; import android.util.Log; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; +import com.android.intentresolver.chooser.TargetInfo; import java.util.Collections; import java.util.Comparator; @@ -65,7 +65,7 @@ class ShortcutSelectionLogic { Context userContext, SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator, int maxRankedTargets, - List serviceTargets) { + List serviceTargets) { if (DEBUG) { Log.d(TAG, "addServiceResults " + (origTarget == null ? null : origTarget.getResolvedComponentName()) + ", " @@ -126,12 +126,12 @@ class ShortcutSelectionLogic { } private boolean insertServiceTarget( - SelectableTargetInfo chooserTargetInfo, + TargetInfo chooserTargetInfo, int maxRankedTargets, - List serviceTargets) { + List serviceTargets) { // Check for duplicates and abort if found - for (ChooserTargetInfo otherTargetInfo : serviceTargets) { + for (TargetInfo otherTargetInfo : serviceTargets) { if (chooserTargetInfo.isSimilar(otherTargetInfo)) { return false; } @@ -141,7 +141,7 @@ class ShortcutSelectionLogic { final float newScore = chooserTargetInfo.getModifiedScore(); for (int i = 0; i < Math.min(currentSize, maxRankedTargets); i++) { - final ChooserTargetInfo serviceTarget = serviceTargets.get(i); + final TargetInfo serviceTarget = serviceTargets.get(i); if (serviceTarget == null) { serviceTargets.set(i, chooserTargetInfo); return true; diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index a1ba87f8..38a2759e 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.service.chooser.ChooserTarget; import android.text.TextUtils; @@ -25,59 +24,14 @@ import android.text.TextUtils; * Direct Share deep link into an application. */ public abstract class ChooserTargetInfo implements TargetInfo { - /** - * @return the target score, including any Chooser-specific modifications that may have been - * applied (either overriding by special-case for "non-selectable" targets, or by twiddling the - * scores of "selectable" targets in {@link ChooserListAdapter}). Higher scores are "better." - */ - public abstract float getModifiedScore(); - - /** - * @return the {@link ChooserTarget} record that contains additional data about this target, if - * any. This is only non-null for selectable targets (and probably only Direct Share targets?). - * - * @deprecated {@link ChooserTarget} (and any other related {@code ChooserTargetService} APIs) - * got deprecated as part of sunsetting that old system design, but for historical reasons - * Chooser continues to shoehorn data from other sources into this representation to maintain - * compatibility with legacy internal APIs. New clients should avoid taking any further - * dependencies on the {@link ChooserTarget} type; any data they want to query from those - * records should instead be pulled up to new query methods directly on this class (or on the - * root {@link TargetInfo}). - */ - @Deprecated - @Nullable - public abstract ChooserTarget getChooserTarget(); @Override public final boolean isChooserTargetInfo() { return true; } - /** - * Attempt to load the display icon, if we have the info for one but it hasn't been loaded yet. - * @return true if an icon may have been loaded as the result of this operation, potentially - * prompting a UI refresh. If this returns false, clients can safely assume there was no change. - */ - public boolean loadIcon() { - return false; - } - - /** - * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available. - * TODO: determine the meaning of a TargetInfo (ChooserTargetInfo) embedding another kind of - * TargetInfo (DisplayResolveInfo) in this way, and - at least - improve this documentation; - * OTOH this probably indicates an opportunity to simplify or better separate these APIs. - */ - @Nullable - public DisplayResolveInfo getDisplayResolveInfo() { - return null; - } - - /** - * Do not label as 'equals', since this doesn't quite work - * as intended with java 8. - */ - public boolean isSimilar(ChooserTargetInfo other) { + @Override + public boolean isSimilar(TargetInfo other) { if (other == null) return false; ChooserTarget ct1 = getChooserTarget(); diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 3a488e32..66e2022c 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -38,7 +38,7 @@ import java.util.List; */ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { /** Create a non-selectable {@link TargetInfo} with no content. */ - public static ChooserTargetInfo newEmptyTargetInfo() { + public static TargetInfo newEmptyTargetInfo() { return new NotSelectableTargetInfo() { @Override public boolean isEmptyTargetInfo() { @@ -61,7 +61,7 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { * Create a non-selectable {@link TargetInfo} with placeholder content to be displayed * unless/until it can be replaced by the result of a pending asynchronous load. */ - public static ChooserTargetInfo newPlaceHolderTargetInfo() { + public static TargetInfo newPlaceHolderTargetInfo() { return new NotSelectableTargetInfo() { @Override public boolean isPlaceHolderTargetInfo() { diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 220b6467..4842cd80 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,6 +17,7 @@ package com.android.intentresolver.chooser; +import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Context; @@ -25,10 +26,12 @@ import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +import android.service.chooser.ChooserTarget; import com.android.intentresolver.ResolverActivity; import java.util.List; +import java.util.Objects; /** * A single target as represented in the chooser. @@ -135,6 +138,77 @@ public interface TargetInfo { */ boolean isPinned(); + /** + * Determine whether two targets represent "similar" content that could be de-duped. + * Note an earlier version of this code cautioned maintainers, + * "do not label as 'equals', since this doesn't quite work as intended with java 8." + * This seems to refer to the rule that interfaces can't provide defaults that conflict with the + * definitions of "real" methods in {@code java.lang.Object}, and (if desired) it could be + * presumably resolved by converting {@code TargetInfo} from an interface to an abstract class. + */ + default boolean isSimilar(TargetInfo other) { + return Objects.equals(this, other); + } + + /** + * @return the target score, including any Chooser-specific modifications that may have been + * applied (either overriding by special-case for "non-selectable" targets, or by twiddling the + * scores of "selectable" targets in {@link ChooserListAdapter}). Higher scores are "better." + * Targets that aren't intended for ranking/scoring should return a negative value. + */ + default float getModifiedScore() { + return -0.1f; + } + + /** + * @return the {@link ChooserTarget} record that contains additional data about this target, if + * any. This is only non-null for selectable targets (and probably only Direct Share targets?). + * + * @deprecated {@link ChooserTarget} (and any other related {@code ChooserTargetService} APIs) + * got deprecated as part of sunsetting that old system design, but for historical reasons + * Chooser continues to shoehorn data from other sources into this representation to maintain + * compatibility with legacy internal APIs. New clients should avoid taking any further + * dependencies on the {@link ChooserTarget} type; any data they want to query from those + * records should instead be pulled up to new query methods directly on this class (or on the + * root {@link TargetInfo}). + */ + @Deprecated + @Nullable + default ChooserTarget getChooserTarget() { + return null; + } + + /** + * Attempt to load the display icon, if we have the info for one but it hasn't been loaded yet. + * @return true if an icon may have been loaded as the result of this operation, potentially + * prompting a UI refresh. If this returns false, clients can safely assume there was no change. + */ + default boolean loadIcon() { + return false; + } + + /** + * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available. + * TODO: this seems to return non-null only for ChooserTargetInfo subclasses. Determine the + * meaning of a TargetInfo (ChooserTargetInfo) embedding another kind of TargetInfo + * (DisplayResolveInfo) in this way, and - at least - improve this documentation; OTOH this + * probably indicates an opportunity to simplify or better separate these APIs. (For example, + * targets that don't descend from ChooserTargetInfo instead descend directly from + * DisplayResolveInfo; should they return `this`? Do we always use DisplayResolveInfo to + * represent visual properties, and then either assume some implicit metadata properties *or* + * embed that visual representation within a ChooserTargetInfo to carry additional metadata? If + * that's the case, maybe we could decouple by saying that all TargetInfos compose-in their + * visual representation [as a DisplayResolveInfo, now the root of its own class hierarchy] and + * then add a new TargetInfo type that explicitly represents the "implicit metadata" that we + * previously assumed for "naked DisplayResolveInfo targets" that weren't wrapped as + * ChooserTargetInfos. Or does all this complexity disappear once we stop relying on the + * deprecated ChooserTarget type?) + */ + @Nullable + default DisplayResolveInfo getDisplayResolveInfo() { + return null; + } + /** * @return true if this target represents a legacy {@code ChooserTargetInfo}. These objects were * historically documented as representing "[a] TargetInfo for Direct Share." However, not all diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 116d49bf..153aa057 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -31,6 +31,7 @@ import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask import com.android.intentresolver.chooser.ChooserTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator +import com.android.intentresolver.chooser.TargetInfo import com.android.internal.R import org.junit.Before import org.junit.Test @@ -57,7 +58,7 @@ class ChooserListAdapterTest { private val chooserActivityLogger = mock() private fun createChooserListAdapter( - taskProvider: (ChooserTargetInfo?) -> LoadDirectShareIconTask + taskProvider: (TargetInfo?) -> LoadDirectShareIconTask ) = object : ChooserListAdapter( context, emptyList(), @@ -71,7 +72,7 @@ class ChooserListAdapterTest { chooserActivityLogger, ) { override fun createLoadDirectShareIconTask( - info: ChooserTargetInfo? + info: TargetInfo? ): LoadDirectShareIconTask = taskProvider(info) } @@ -120,7 +121,7 @@ class ChooserListAdapterTest { verify(testTaskProvider, times(1)).invoke() } - private fun createSelectableTargetInfo(): ChooserTargetInfo = + private fun createSelectableTargetInfo(): TargetInfo = SelectableTargetInfo( context, null, diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 052ad446..16e0071e 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -21,7 +21,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutInfo import android.service.chooser.ChooserTarget -import com.android.intentresolver.chooser.ChooserTargetInfo +import com.android.intentresolver.chooser.TargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -52,7 +52,7 @@ class ShortcutSelectionLogicTest { @Test fun testAddShortcuts_no_limits() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] val testSubject = ShortcutSelectionLogic( @@ -82,7 +82,7 @@ class ShortcutSelectionLogicTest { @Test fun testAddShortcuts_same_package_with_per_package_limit() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] val testSubject = ShortcutSelectionLogic( @@ -112,7 +112,7 @@ class ShortcutSelectionLogicTest { @Test fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] val testSubject = ShortcutSelectionLogic( @@ -142,7 +142,7 @@ class ShortcutSelectionLogicTest { @Test fun testAddShortcuts_different_packages_with_per_package_limit() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val pkgAsc1 = packageTargets[PACKAGE_A, 0] val pkgAsc2 = packageTargets[PACKAGE_A, 1] val pkgBsc1 = packageTargets[PACKAGE_B, 0] @@ -184,7 +184,7 @@ class ShortcutSelectionLogicTest { @Test fun testAddShortcuts_pinned_shortcut() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] val testSubject = ShortcutSelectionLogic( @@ -220,7 +220,7 @@ class ShortcutSelectionLogicTest { @Test fun test_available_caller_shortcuts_count_is_limited() { - val serviceResults = ArrayList() + val serviceResults = ArrayList() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] val sc3 = packageTargets[PACKAGE_A, 2] @@ -255,7 +255,7 @@ class ShortcutSelectionLogicTest { } private fun assertShortcutsInOrder( - expected: List, actual: List, msg: String? = "" + expected: List, actual: List, msg: String? = "" ) { assertEquals(msg, expected.size, actual.size) for (i in expected.indices) { @@ -268,4 +268,4 @@ class ShortcutSelectionLogicTest { } private fun String.shortcutId(id: Int) = "$this.$id" -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From e40a936e41697072627a50f798520ee846fc8798 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 14 Oct 2022 14:45:12 -0400 Subject: Use factories, not constructors, in TargetInfo API This is often a good idea in general, and it's Item #1 in Effective Java: https://effectivejava.googleplex.com/third-edition#page=26 The change as implemented here is in line with the guidance catalogued in Fowler's _Refactoring_ as "Replace Constructor with Factory Function." As both sources indicate, one motivation is that these static factory methods are permitted to return objects of any subtype of their declared return type. As part of my ongoing TargetInfo refactorings (go/chooser-targetinfo-cleanup), we aim to eventually collapse the subtypes into one new data-only class (tentatively named "ImmutableTargetInfo" in my local prototypes). Then these factory methods can start returning instances of that new concrete type, preconfigured to behave equivalently to whichever legacy TargetInfo subclass currently hosts the factory method. Finally, we'll be able to pull all these factory methods up to the base (probably allowing ImmutableTargetInfo to take over the "TargetInfo" name once there are no other implementations to justify retaining the interface). Previous cleanups have already pulled any methods added by ChooserTargetInfo (or any of its subclasses) up into the base TargetInfo interface so that clients don't need to refer to them by their concrete types. We'll need to make a similar change for the (Multi)DisplayResolveInfo side of the hierarchy before their factories can start returning generic TargetInfos. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ic8b4013fb4cd14f27c5290b0f684a9251cc3ffe2 --- .../android/intentresolver/ChooserActivity.java | 11 +- .../android/intentresolver/ChooserListAdapter.java | 7 +- .../intentresolver/ResolverListAdapter.java | 19 ++- .../intentresolver/ShortcutSelectionLogic.java | 2 +- .../intentresolver/chooser/DisplayResolveInfo.java | 56 +++++++-- .../chooser/MultiDisplayResolveInfo.java | 10 +- .../chooser/SelectableTargetInfo.java | 27 +++- .../intentresolver/ChooserListAdapterTest.kt | 3 +- .../intentresolver/ChooserWrapperActivity.java | 7 +- .../intentresolver/ResolverDataProvider.java | 6 +- .../src/com/android/intentresolver/TestHelpers.kt | 2 +- .../intentresolver/chooser/TargetInfoTest.kt | 139 +++++++++++++++++++++ 12 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 63ab20cd..fcbd501d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1120,8 +1120,13 @@ public class ChooserActivity extends ResolverActivity implements return null; } - final DisplayResolveInfo dri = new DisplayResolveInfo( - originalIntent, ri, getString(com.android.internal.R.string.screenshot_edit), "", resolveIntent, null); + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + getString(com.android.internal.R.string.screenshot_edit), + "", + resolveIntent, + null); dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; } @@ -1164,7 +1169,7 @@ public class ChooserActivity extends ResolverActivity implements icon = ri.loadIcon(getPackageManager()); } - final DisplayResolveInfo dri = new DisplayResolveInfo( + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( originalIntent, ri, name, "", resolveIntent, null); dri.setDisplayIcon(icon); return dri; diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 9c5c979c..8fd95ab8 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -209,7 +209,9 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.noResourceId = true; ri.icon = 0; } - mCallerTargets.add(new DisplayResolveInfo(ii, ri, ii, makePresentationGetter(ri))); + DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( + ii, ri, ii, makePresentationGetter(ri)); + mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } } @@ -361,7 +363,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } else { // create consolidated target from the single DisplayResolveInfo MultiDisplayResolveInfo multiDisplayResolveInfo = - new MultiDisplayResolveInfo(resolvedTarget, multiDri); + MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + resolvedTarget, multiDri); multiDisplayResolveInfo.addTarget(info); consolidated.put(resolvedTarget, multiDisplayResolveInfo); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 0e58aff8..2d8c932c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -445,8 +445,13 @@ public class ResolverListAdapter extends BaseAdapter { ri.icon = 0; } - addResolveInfo(new DisplayResolveInfo(ii, ri, - ri.loadLabel(mPm), null, ii, makePresentationGetter(ri))); + addResolveInfo(DisplayResolveInfo.newDisplayResolveInfo( + ii, + ri, + ri.loadLabel(mPm), + null, + ii, + makePresentationGetter(ri))); } } @@ -495,9 +500,11 @@ public class ResolverListAdapter extends BaseAdapter { mResolverListCommunicator.getReplacementIntent(add.activityInfo, intent); final Intent defaultIntent = mResolverListCommunicator.getReplacementIntent( add.activityInfo, mResolverListCommunicator.getTargetIntent()); - final DisplayResolveInfo - dri = new DisplayResolveInfo(intent, add, - replaceIntent != null ? replaceIntent : defaultIntent, makePresentationGetter(add)); + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + intent, + add, + (replaceIntent != null) ? replaceIntent : defaultIntent, + makePresentationGetter(add)); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -828,7 +835,7 @@ public class ResolverListAdapter extends BaseAdapter { ResolveInfoPresentationGetter presentationGetter = new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo); - return new DisplayResolveInfo( + return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 07573d1b..a278baae 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -99,7 +99,7 @@ class ShortcutSelectionLogic { targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; } boolean isInserted = insertServiceTarget( - new SelectableTargetInfo( + SelectableTargetInfo.newSelectableTargetInfo( userContext, origTarget, target, diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 8f950323..5e1b8cab 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -52,30 +52,60 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; private boolean mPinned = false; - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, Intent pOrigIntent, - ResolveInfoPresentationGetter resolveInfoPresentationGetter) { - this(originalIntent, pri, null /*mDisplayLabel*/, null /*mExtendedInfo*/, pOrigIntent, + /** Create a new {@code DisplayResolveInfo} instance. */ + public static DisplayResolveInfo newDisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + @NonNull Intent resolvedIntent, + @Nullable ResolveInfoPresentationGetter presentationGetter) { + return newDisplayResolveInfo( + originalIntent, + resolveInfo, + /* displayLabel=*/ null, + /* extendedInfo=*/ null, + resolvedIntent, + presentationGetter); + } + + /** Create a new {@code DisplayResolveInfo} instance. */ + public static DisplayResolveInfo newDisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + @NonNull Intent resolvedIntent, + @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + return new DisplayResolveInfo( + originalIntent, + resolveInfo, + displayLabel, + extendedInfo, + resolvedIntent, resolveInfoPresentationGetter); } - public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, - CharSequence pInfo, @NonNull Intent resolvedIntent, + private DisplayResolveInfo( + Intent originalIntent, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + @NonNull Intent resolvedIntent, @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { mSourceIntents.add(originalIntent); - mResolveInfo = pri; - mDisplayLabel = pLabel; - mExtendedInfo = pInfo; + mResolveInfo = resolveInfo; + mDisplayLabel = displayLabel; + mExtendedInfo = extendedInfo; mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + final ActivityInfo ai = mResolveInfo.activityInfo; + mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; + final Intent intent = new Intent(resolvedIntent); intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); - final ActivityInfo ai = mResolveInfo.activityInfo; intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); - - mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - mResolvedIntent = intent; + } private DisplayResolveInfo(DisplayResolveInfo other, Intent fillInIntent, int flags, @@ -90,7 +120,7 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { mResolveInfoPresentationGetter = resolveInfoPresentationGetter; } - DisplayResolveInfo(DisplayResolveInfo other) { + protected DisplayResolveInfo(DisplayResolveInfo other) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mDisplayLabel = other.mDisplayLabel; diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index e1fe58fd..f5fc97c1 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -38,7 +38,15 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { /** * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. */ - public MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) { + public static MultiDisplayResolveInfo newMultiDisplayResolveInfo( + String packageName, DisplayResolveInfo firstInfo) { + return new MultiDisplayResolveInfo(packageName, firstInfo); + } + + /** + * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. + */ + private MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) { super(firstInfo); mBaseInfo = firstInfo; mTargetInfos.add(firstInfo); diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index b9df02c8..506faa9d 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -73,9 +73,29 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final float mModifiedScore; private boolean mIsSuspended = false; - public SelectableTargetInfo(Context context, @Nullable DisplayResolveInfo sourceInfo, + /** Create a new {@link TargetInfo} instance representing a selectable target. */ + public static TargetInfo newSelectableTargetInfo( + Context context, + @Nullable DisplayResolveInfo sourceInfo, ChooserTarget chooserTarget, - float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator, + float modifiedScore, + SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, + @Nullable ShortcutInfo shortcutInfo) { + return new SelectableTargetInfo( + context, + sourceInfo, + chooserTarget, + modifiedScore, + selectableTargetInfoCommunicator, + shortcutInfo); + } + + private SelectableTargetInfo( + Context context, + @Nullable DisplayResolveInfo sourceInfo, + ChooserTarget chooserTarget, + float modifiedScore, + SelectableTargetInfoCommunicator selectableTargetInfoComunicator, @Nullable ShortcutInfo shortcutInfo) { mContext = context; mSourceInfo = sourceInfo; @@ -112,8 +132,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle()); } - private SelectableTargetInfo(SelectableTargetInfo other, - Intent fillInIntent, int flags) { + private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { mContext = other.mContext; mPm = other.mPm; mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator; diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 153aa057..f50d3b12 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -28,7 +28,6 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask -import com.android.intentresolver.chooser.ChooserTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator import com.android.intentresolver.chooser.TargetInfo @@ -122,7 +121,7 @@ class ChooserListAdapterTest { } private fun createSelectableTargetInfo(): TargetInfo = - SelectableTargetInfo( + SelectableTargetInfo.newSelectableTargetInfo( context, null, createChooserTarget(), diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index ac5d30f8..079fbb9d 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -222,7 +222,12 @@ public class ChooserWrapperActivity public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { - return new DisplayResolveInfo(originalIntent, pri, pLabel, pInfo, replacementIntent, + return DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + pri, + pLabel, + pInfo, + replacementIntent, resolveInfoPresentationGetter); } diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 33e7123f..01d07639 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -32,7 +32,7 @@ import android.test.mock.MockResources; /** * Utility class used by resolver tests to create mock data */ -class ResolverDataProvider { +public class ResolverDataProvider { static private int USER_SOMEONE_ELSE = 10; @@ -52,12 +52,12 @@ class ResolverDataProvider { createResolverIntent(i), createResolveInfo(i, userId)); } - static ComponentName createComponentName(int i) { + public static ComponentName createComponentName(int i) { final String name = "component" + i; return new ComponentName("foo.bar." + name, name); } - static ResolveInfo createResolveInfo(int i, int userId) { + public static ResolveInfo createResolveInfo(int i, int userId) { final ResolveInfo resolveInfo = new ResolveInfo(); resolveInfo.activityInfo = createActivityInfo(i); resolveInfo.targetUserId = userId; diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt index f4b83249..5b583fef 100644 --- a/java/tests/src/com/android/intentresolver/TestHelpers.kt +++ b/java/tests/src/com/android/intentresolver/TestHelpers.kt @@ -59,7 +59,7 @@ internal fun createAppTarget(shortcutInfo: ShortcutInfo) = shortcutInfo.activity?.className ?: error("missing activity info") ) -internal fun createChooserTarget( +fun createChooserTarget( title: String, score: Float, componentName: ComponentName, shortcutId: String ): ChooserTarget = ChooserTarget( diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt new file mode 100644 index 00000000..668eb76e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2022 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.intentresolver.chooser + +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.service.chooser.ChooserTarget +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.createChooserTarget +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TargetInfoTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + @Test + fun testNewEmptyTargetInfo() { + val info = NotSelectableTargetInfo.newEmptyTargetInfo() + assertThat(info.isEmptyTargetInfo()).isTrue() + assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.hasDisplayIcon()).isFalse() + assertThat(info.getDisplayIcon(context)).isNull() + } + + @Test + fun testNewPlaceholderTargetInfo() { + val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo() + assertThat(info.isPlaceHolderTargetInfo()).isTrue() + assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.hasDisplayIcon()).isTrue() + // TODO: test infrastructure isn't set up to assert anything about the icon itself. + } + + @Test + fun testNewSelectableTargetInfo() { + val displayInfo: DisplayResolveInfo = mock() + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val selectableTargetInfoCommunicator: SelectableTargetInfoCommunicator = mock() + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + context, + displayInfo, + chooserTarget, + 0.1f, + selectableTargetInfoCommunicator, + shortcutInfo) + assertThat(targetInfo.isSelectableTargetInfo()).isTrue() + assertThat(targetInfo.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(targetInfo.getDisplayResolveInfo()).isSameInstanceAs(displayInfo) + assertThat(targetInfo.getChooserTarget()).isSameInstanceAs(chooserTarget) + // TODO: make more meaningful assertions about the behavior of a selectable target. + } + + @Test + fun testNewDisplayResolveInfo() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") + intent.setType("text/plain") + + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + + val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label", + "extended info", + intent, + /* resolveInfoPresentationGetter= */ null) + assertThat(targetInfo.isDisplayResolveInfo()).isTrue() + assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() + assertThat(targetInfo.isChooserTargetInfo()).isFalse() + } + + @Test + fun testNewMultiDisplayResolveInfo() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") + intent.setType("text/plain") + + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label 1", + "extended info 1", + intent, + /* resolveInfoPresentationGetter= */ null) + val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label 2", + "extended info 2", + intent, + /* resolveInfoPresentationGetter= */ null) + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + "", firstTargetInfo) + multiTargetInfo.addTarget(secondTargetInfo) + + assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() + assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. + assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse() + + assertThat(multiTargetInfo.getExtendedInfo()).isNull() + + assertThat(multiTargetInfo.getTargets()).containsExactly(firstTargetInfo, secondTargetInfo) + + assertThat(multiTargetInfo.hasSelected()).isFalse() + assertThat(multiTargetInfo.getSelectedTarget()).isNull() + + multiTargetInfo.setSelected(1) + + assertThat(multiTargetInfo.hasSelected()).isTrue() + assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + + // TODO: consider exercising activity-start behavior. + // TODO: consider exercising DisplayResolveInfo base class behavior. + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From 5c5fc691489ef7d03b0258b1af82b51b48b9dec5 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 19 Oct 2022 15:59:19 -0400 Subject: Make "stacked" target membership immutable This includes a readability improvement in `ChooserListAdapter` to precompute the grouping for "consolidated" targets *before* building them into a `MultiDisplayResolveInfo` so that we don't need to accumulate targets into the respective stacks incrementally as we process the list. This is the only time targets were being added into a `MultiDisplayResolveInfo` -- they were already effectively final after this one-time "consolidation" is complete. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I230131585a7087f7a839e58d1d5a2dbdf66c3712 --- .../android/intentresolver/ChooserListAdapter.java | 41 ++++++++++------------ .../chooser/MultiDisplayResolveInfo.java | 35 +++++++++--------- .../intentresolver/chooser/TargetInfoTest.kt | 3 +- 3 files changed, 35 insertions(+), 44 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 8fd95ab8..85b50ab6 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -53,10 +53,10 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { private static final String TAG = "ChooserListAdapter"; @@ -341,6 +341,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } void updateAlphabeticalList() { + // TODO: this procedure seems like it should be relatively lightweight. Why does it need to + // run in an `AsyncTask`? new AsyncTask>() { @Override protected List doInBackground(Void... voids) { @@ -350,29 +352,22 @@ public class ChooserListAdapter extends ResolverListAdapter { if (!mEnableStackedApps) { return allTargets; } + // Consolidate multiple targets from same app. - Map consolidated = new HashMap<>(); - for (DisplayResolveInfo info : allTargets) { - String resolvedTarget = info.getResolvedComponentName().getPackageName() - + '#' + info.getDisplayLabel(); - DisplayResolveInfo multiDri = consolidated.get(resolvedTarget); - if (multiDri == null) { - consolidated.put(resolvedTarget, info); - } else if (multiDri.isMultiDisplayResolveInfo()) { - ((MultiDisplayResolveInfo) multiDri).addTarget(info); - } else { - // create consolidated target from the single DisplayResolveInfo - MultiDisplayResolveInfo multiDisplayResolveInfo = - MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - resolvedTarget, multiDri); - multiDisplayResolveInfo.addTarget(info); - consolidated.put(resolvedTarget, multiDisplayResolveInfo); - } - } - List groupedTargets = new ArrayList<>(); - groupedTargets.addAll(consolidated.values()); - Collections.sort(groupedTargets, new ChooserActivity.AzInfoComparator(mContext)); - return groupedTargets; + return allTargets + .stream() + .collect(Collectors.groupingBy(target -> + target.getResolvedComponentName().getPackageName() + + "#" + target.getDisplayLabel() + )) + .values() + .stream() + .map(appTargets -> + (appTargets.size() == 1) + ? appTargets.get(0) + : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets)) + .sorted(new ChooserActivity.AzInfoComparator(mContext)) + .collect(Collectors.toList()); } @Override protected void onPostExecute(List newList) { diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index f5fc97c1..bad36077 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -23,33 +23,36 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverActivity; import java.util.ArrayList; +import java.util.List; /** * Represents a "stack" of chooser targets for various activities within the same component. */ public class MultiDisplayResolveInfo extends DisplayResolveInfo { + /* TODO: hold as a generic `List` once we're unconstrained by the TODO + * regarding the return type of `#getTargets()`. */ ArrayList mTargetInfos = new ArrayList<>(); - // We'll use this DRI for basic presentation info - eg icon, name. - final DisplayResolveInfo mBaseInfo; + // Index of selected target private int mSelected = -1; /** - * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. + * @param targetInfos A list of targets in this stack. The first item is treated as the + * "representative" that provides the main icon, title, etc. */ public static MultiDisplayResolveInfo newMultiDisplayResolveInfo( - String packageName, DisplayResolveInfo firstInfo) { - return new MultiDisplayResolveInfo(packageName, firstInfo); + List targetInfos) { + return new MultiDisplayResolveInfo(targetInfos); } /** - * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. + * @param targetInfos A list of targets in this stack. The first item is treated as the + * "representative" that provides the main icon, title, etc. */ - private MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) { - super(firstInfo); - mBaseInfo = firstInfo; - mTargetInfos.add(firstInfo); + private MultiDisplayResolveInfo(List targetInfos) { + super(targetInfos.get(0)); + mTargetInfos = new ArrayList<>(targetInfos); } @Override @@ -64,14 +67,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } /** - * Add another DisplayResolveInfo to the list included for this target. - */ - public void addTarget(DisplayResolveInfo target) { - mTargetInfos.add(target); - } - - /** - * List of all DisplayResolveInfos included in this target. + * List of all {@link DisplayResolveInfo}s included in this target. + * TODO: provide as a generic {@code List} once {@link ChooserActivity} + * stops requiring the signature to match that of the other "lists" it builds up. */ public ArrayList getTargets() { return mTargetInfos; @@ -109,5 +107,4 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } - } diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 668eb76e..8494e296 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -114,8 +114,7 @@ class TargetInfoTest { /* resolveInfoPresentationGetter= */ null) val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - "", firstTargetInfo) - multiTargetInfo.addTarget(secondTargetInfo) + listOf(firstTargetInfo, secondTargetInfo)) assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. -- cgit v1.2.3-59-g8ed1b From 4b933dfd64a62d8eaba8dc2599c3ad58dc7fc230 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sun, 16 Oct 2022 11:44:33 -0700 Subject: Delegate AppPredictor creation to a factory class A scaffolding chnage that delegates AppPredictor instance creation to a new factory class. As we always create an AppPredictor instance for each available profile, the creation logic is changed from lazy to eager. As a collateral change, remvoe some obsolete flags. Test: manual test Test: atest IntentResolverUnitTests Change-Id: I0cce89bce1fb39d39792263a3a490a074304afe3 --- Android.bp | 1 + .../android/intentresolver/ChooserActivity.java | 168 +++++++-------------- .../com/android/intentresolver/ChooserFlags.java | 33 ---- .../shortcuts/AppPredictorFactory.kt | 67 ++++++++ 4 files changed, 125 insertions(+), 144 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserFlags.java create mode 100644 java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index c2866f8c..c2620c49 100644 --- a/Android.bp +++ b/Android.bp @@ -36,6 +36,7 @@ android_library { min_sdk_version: "current", srcs: [ "java/src/**/*.java", + "java/src/**/*.kt", ], resource_dirs: [ "java/res", diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 63ab20cd..92dd5a01 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -32,8 +32,6 @@ import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; import android.app.SharedElementCallback; -import android.app.prediction.AppPredictionContext; -import android.app.prediction.AppPredictionManager; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -92,6 +90,7 @@ import android.util.Log; import android.util.PluralsMessageFormatter; import android.util.Size; import android.util.Slog; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.View.MeasureSpec; @@ -120,6 +119,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -155,8 +155,6 @@ public class ChooserActivity extends ResolverActivity implements SelectableTargetInfoCommunicator { private static final String TAG = "ChooserActivity"; - private AppPredictor mPersonalAppPredictor; - private AppPredictor mWorkAppPredictor; private boolean mShouldDisplayLandscape; public ChooserActivity() { @@ -184,22 +182,14 @@ public class ChooserActivity extends ResolverActivity implements private static final boolean DEBUG = true; - private static final boolean USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES = true; - // TODO(b/123088566) Share these in a better way. - private static final String APP_PREDICTION_SHARE_UI_SURFACE = "share"; public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; - public static final String CHOOSER_TARGET = "chooser_target"; private static final String SHORTCUT_TARGET = "shortcut_target"; - private static final int APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20; - public static final String APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter"; - private static final String SHARED_TEXT_KEY = "shared_text"; private static final String PLURALS_COUNT = "count"; private static final String PLURALS_FILE_NAME = "file_name"; private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; - private boolean mIsAppPredictorComponentAvailable; private Map mDirectShareAppTargetCache; private Map mDirectShareShortcutInfoCache; @@ -315,8 +305,9 @@ public class ChooserActivity extends ResolverActivity implements private View mContentView = null; - private ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = + private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = new ShortcutToChooserTargetConverter(); + private final SparseArray mProfileAppPredictors = new SparseArray<>(); private class ContentPreviewCoordinator { private static final int IMAGE_FADE_IN_MILLIS = 150; @@ -529,8 +520,6 @@ public class ChooserActivity extends ResolverActivity implements mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); getChooserActivityLogger().logSharesheetTriggered(); - // This is the only place this value is being set. Effectively final. - mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable(); mIsSuccessfullySelected = false; Intent intent = getIntent(); @@ -663,6 +652,11 @@ public class ChooserActivity extends ResolverActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); + createAppPredictors( + new AppPredictorFactory( + this, + target.getStringExtra(Intent.EXTRA_TEXT), + getTargetIntentFilter(target))); super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, null, false); @@ -736,9 +730,25 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } + private void createAppPredictors(AppPredictorFactory factory) { + UserHandle mainUserHandle = getPersonalProfileUserHandle(); + createAppPredictorForProfile(mainUserHandle, factory); + UserHandle workUserHandle = getWorkProfileUserHandle(); + if (workUserHandle != null) { + createAppPredictorForProfile(workUserHandle, factory); + } + } + + private void createAppPredictorForProfile(UserHandle userHandle, AppPredictorFactory factory) { + AppPredictor appPredictor = factory.create(userHandle); + if (appPredictor != null) { + mProfileAppPredictors.put(userHandle.getIdentifier(), appPredictor); + } + } + private AppPredictor setupAppPredictorForUser(UserHandle userHandle, AppPredictor.Callback appPredictorCallback) { - AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); + AppPredictor appPredictor = getAppPredictor(userHandle); if (appPredictor == null) { return null; } @@ -882,13 +892,6 @@ public class ChooserActivity extends ResolverActivity implements return postRebuildListInternal(rebuildCompleted); } - /** - * Returns true if app prediction service is defined and the component exists on device. - */ - private boolean isAppPredictionServiceAvailable() { - return getPackageManager().getAppPredictionServicePackageName() != null; - } - /** * Check if the profile currently used is a work profile. * @return true if it is work profile, false if it is parent profile (or no work profile is @@ -1626,8 +1629,7 @@ public class ChooserActivity extends ResolverActivity implements if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) { mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor(); } - mPersonalAppPredictor = null; - mWorkAppPredictor = null; + mProfileAppPredictors.clear(); } @Override // ResolverListCommunicator @@ -1935,8 +1937,11 @@ public class ChooserActivity extends ResolverActivity implements } private IntentFilter getTargetIntentFilter() { + return getTargetIntentFilter(getTargetIntent()); + } + + private IntentFilter getTargetIntentFilter(final Intent intent) { try { - final Intent intent = getTargetIntent(); String dataString = intent.getDataString(); if (intent.getType() == null) { if (!TextUtils.isEmpty(dataString)) { @@ -1976,7 +1981,7 @@ public class ChooserActivity extends ResolverActivity implements mQueriedSharingShortcutsTimeMs = System.currentTimeMillis(); UserHandle userHandle = adapter.getUserHandle(); if (!skipAppPredictionService) { - AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); + AppPredictor appPredictor = getAppPredictor(userHandle); if (appPredictor != null) { appPredictor.requestPredictionUpdate(); return; @@ -2146,15 +2151,16 @@ public class ChooserActivity extends ResolverActivity implements } private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { - AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { - return; - } // Send DS target impression info to AppPredictor, only when user chooses app share. if (targetInfo.isChooserTargetInfo()) { return; } + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); List targetIds = new ArrayList<>(); for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { @@ -2171,12 +2177,13 @@ public class ChooserActivity extends ResolverActivity implements } private void sendClickToAppPredictor(TargetInfo targetInfo) { - AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { + if (!targetInfo.isChooserTargetInfo()) { return; } - if (!targetInfo.isChooserTargetInfo()) { + + AppPredictor directShareAppPredictor = getAppPredictor( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { return; } ChooserTarget chooserTarget = targetInfo.getChooserTarget(); @@ -2194,70 +2201,8 @@ public class ChooserActivity extends ResolverActivity implements } @Nullable - private AppPredictor createAppPredictor(UserHandle userHandle) { - if (!mIsAppPredictorComponentAvailable) { - return null; - } - - if (getPersonalProfileUserHandle().equals(userHandle)) { - if (mPersonalAppPredictor != null) { - return mPersonalAppPredictor; - } - } else { - if (mWorkAppPredictor != null) { - return mWorkAppPredictor; - } - } - - // TODO(b/148230574): Currently AppPredictor fetches only the same-profile app targets. - // Make AppPredictor work cross-profile. - Context contextAsUser = createContextAsUser(userHandle, 0 /* flags */); - final IntentFilter filter = getTargetIntentFilter(); - Bundle extras = new Bundle(); - extras.putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, filter); - populateTextContent(extras); - AppPredictionContext appPredictionContext = new AppPredictionContext.Builder(contextAsUser) - .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) - .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) - .setExtras(extras) - .build(); - AppPredictionManager appPredictionManager = - contextAsUser - .getSystemService(AppPredictionManager.class); - AppPredictor appPredictionSession = appPredictionManager.createAppPredictionSession( - appPredictionContext); - if (getPersonalProfileUserHandle().equals(userHandle)) { - mPersonalAppPredictor = appPredictionSession; - } else { - mWorkAppPredictor = appPredictionSession; - } - return appPredictionSession; - } - - private void populateTextContent(Bundle extras) { - final Intent intent = getTargetIntent(); - String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); - extras.putString(SHARED_TEXT_KEY, sharedText); - } - - /** - * This will return an app predictor if it is enabled for direct share sorting - * and if one exists. Otherwise, it returns null. - * @param userHandle - */ - @Nullable - private AppPredictor getAppPredictorForDirectShareIfEnabled(UserHandle userHandle) { - return ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS - && !ActivityManager.isLowRamDeviceStatic() ? createAppPredictor(userHandle) : null; - } - - /** - * This will return an app predictor if it is enabled for share activity sorting - * and if one exists. Otherwise, it returns null. - */ - @Nullable - private AppPredictor getAppPredictorForShareActivitiesIfEnabled(UserHandle userHandle) { - return USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES ? createAppPredictor(userHandle) : null; + private AppPredictor getAppPredictor(UserHandle userHandle) { + return mProfileAppPredictors.get(userHandle.getIdentifier(), null); } void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { @@ -2374,10 +2319,13 @@ public class ChooserActivity extends ResolverActivity implements ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, initialIntents, rList, filterLastUsed, createListController(userHandle)); - AppPredictor.Callback appPredictorCallback = createAppPredictorCallback(chooserListAdapter); - AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback); - chooserListAdapter.setAppPredictor(appPredictor); - chooserListAdapter.setAppPredictorCallback(appPredictorCallback); + if (!ActivityManager.isLowRamDeviceStatic()) { + AppPredictor.Callback appPredictorCallback = + createAppPredictorCallback(chooserListAdapter); + AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback); + chooserListAdapter.setAppPredictor(appPredictor); + chooserListAdapter.setAppPredictorCallback(appPredictorCallback); + } return new ChooserGridAdapter(chooserListAdapter); } @@ -2393,7 +2341,7 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected ResolverListController createListController(UserHandle userHandle) { - AppPredictor appPredictor = getAppPredictorForShareActivitiesIfEnabled(userHandle); + AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), @@ -2681,13 +2629,11 @@ public class ChooserActivity extends ResolverActivity implements return; } - if (ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS) { - if (DEBUG) { - Log.d(TAG, "querying direct share targets from ShortcutManager"); - } - - queryDirectShareTargets(chooserListAdapter, false); + if (DEBUG) { + Log.d(TAG, "querying direct share targets from ShortcutManager"); } + + queryDirectShareTargets(chooserListAdapter, false); } @VisibleForTesting diff --git a/java/src/com/android/intentresolver/ChooserFlags.java b/java/src/com/android/intentresolver/ChooserFlags.java deleted file mode 100644 index 67f9046f..00000000 --- a/java/src/com/android/intentresolver/ChooserFlags.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.intentresolver; - -import android.app.prediction.AppPredictionManager; - -/** - * Common flags for {@link ChooserListAdapter} and {@link ChooserActivity}. - */ -public class ChooserFlags { - - /** - * Whether to use {@link AppPredictionManager} to query for direct share targets (as opposed to - * talking directly to {@link android.content.pm.ShortcutManager}. - */ - // TODO(b/123089490): Replace with system flag - static final boolean USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS = true; -} - diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt new file mode 100644 index 00000000..82f40b91 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts + +import android.app.prediction.AppPredictionContext +import android.app.prediction.AppPredictionManager +import android.app.prediction.AppPredictor +import android.content.Context +import android.content.IntentFilter +import android.os.Bundle +import android.os.UserHandle + +// TODO(b/123088566) Share these in a better way. +private const val APP_PREDICTION_SHARE_UI_SURFACE = "share" +private const val APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20 +private const val APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter" +private const val SHARED_TEXT_KEY = "shared_text" + +/** + * A factory to create an AppPredictor instance for a profile, if available. + * @param context, application context + * @param sharedText, a shared text associated with the Chooser's target intent + * (see [android.content.Intent.EXTRA_TEXT]). + * Will be mapped to app predictor's "shared_text" parameter. + * @param targetIntentFilter, an IntentFilter to match direct share targets against. + * Will be mapped app predictor's "intent_filter" parameter. + */ +class AppPredictorFactory( + private val context: Context, + private val sharedText: String?, + private val targetIntentFilter: IntentFilter? +) { + private val mIsComponentAvailable = + context.packageManager.appPredictionServicePackageName != null + + /** + * Creates an AppPredictor instance for a profile or `null` if app predictor is not available. + */ + fun create(userHandle: UserHandle): AppPredictor? { + if (!mIsComponentAvailable) return null + val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) + val extras = Bundle().apply { + putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) + putString(SHARED_TEXT_KEY, sharedText) + } + val appPredictionContext = AppPredictionContext.Builder(contextAsUser) + .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) + .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) + .setExtras(extras) + .build() + return contextAsUser.getSystemService(AppPredictionManager::class.java) + ?.createAppPredictionSession(appPredictionContext) + } +} -- cgit v1.2.3-59-g8ed1b From f90cce574d9b73f2bdc450c8d9d40fb3fc8c50bf Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 20 Oct 2022 21:24:16 -0700 Subject: Code synchronization with the core Resolver As unbounded Resolver is not available at the moment, this change just reflects ag/20252644. Test: smoke test of the Chooser functionality Test: atest IntentResolverUnitTests Change-Id: I3048e76427878df17043ce5ee203d1d4ede04466 --- .../com/android/intentresolver/ResolverListAdapter.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 0e58aff8..e6d19e47 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -646,15 +646,16 @@ public class ResolverListAdapter extends BaseAdapter { if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; - boolean hasLabel = dri.hasDisplayLabel(); - holder.bindLabel( - dri.getDisplayLabel(), - dri.getExtendedInfo(), - hasLabel && alwaysShowSubLabel()); - holder.bindIcon(info); - if (!hasLabel) { + if (dri.hasDisplayLabel()) { + holder.bindLabel( + dri.getDisplayLabel(), + dri.getExtendedInfo(), + alwaysShowSubLabel()); + } else { + holder.bindLabel("", "", false); loadLabel(dri); } + holder.bindIcon(info); if (!dri.hasDisplayIcon()) { loadIcon(dri); } -- cgit v1.2.3-59-g8ed1b From deb9ddfb590c7533e1be565c40f9a522071c19a9 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 20 Oct 2022 09:18:06 -0400 Subject: Rename & pull `getTargets` up to base `TargetInfo` This was originally only offered on `MultiDisplayResolveInfo` but that just resulted in some type-checked conditional logic where the caller (`ChooserActivity`) took responsibility for implementing the other cases. Thus this CL is mainly based on the "Replace Conditional with Polymorphism" item in Fowler's _Refactoring_. (It also separates one piece of unrelated conditional behavior from the call site in `ChooserActivity` but leaves any further cleanup of that separate behavior out of scope.) The method rename is an attempt to call attention to the use of the legacy `DisplayResolveInfo` type (as distinguished from other types of "targets" i.e. `ChooserTargetInfo`), in advance of any cleanup of that design. This both simplifies the call site (in `ChooserActivity`) and consolidates `TargetInfo` requirements that were previously diffuse. It also progresses several of our tactical goals in the ongoing `TargetInfo` cleanup (go/chooser-targetinfo-cleanup): * In order to unify the types, all methods offered by any `TargetInfo` implementation have to be pulled up to the base interface; this CL prioritizes one method of particular benefit to callers in its new location, but it was going to have to move eventually regardless. * This encapsulates one of the two call sites of `TargetInfo.getDisplayResolveInfo()`. That API assumes callers have an understanding of the semantics of the legacy `DisplayResolveInfo` implementation, along with the semantics of the (legacy) concrete type of any other `TargetInfo` they might hold (in order to know whether they can use it as-is vs. descending through its `getDisplayResolveInfo()`). The new API focuses (slightly) more on the application requirements that client is trying to fulfill. * More broadly, this reduces call sites where clients depend on the (new-but-deprecated) `TargetInfo` methods that query the legacy type information. These usages _all_ need to be replaced with new APIs that are more expressive in the application domain. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ibc6cbfb01f323db4b820053babfeb5f7c8024f26 --- .../android/intentresolver/ChooserActivity.java | 35 ++++++++++------------ .../ChooserStackedAppDialogFragment.java | 2 +- .../intentresolver/chooser/ChooserTargetInfo.java | 13 ++++++++ .../intentresolver/chooser/DisplayResolveInfo.java | 6 ++++ .../chooser/MultiDisplayResolveInfo.java | 5 ++-- .../android/intentresolver/chooser/TargetInfo.java | 23 ++++++++++++++ .../intentresolver/chooser/TargetInfoTest.kt | 3 +- 7 files changed, 62 insertions(+), 25 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 26c7fbc9..91f1ce74 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1712,18 +1712,24 @@ public class ChooserActivity extends ResolverActivity implements private void showTargetDetails(TargetInfo targetInfo) { if (targetInfo == null) return; - ArrayList targetList; + List targetList = targetInfo.getAllDisplayTargets(); + if (targetList.isEmpty()) { + Log.e(TAG, "No displayable data to show target details"); + return; + } + ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(); Bundle bundle = new Bundle(); + bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, + new ArrayList<>(targetList)); + if (targetInfo.isSelectableTargetInfo()) { - if (targetInfo.getDisplayResolveInfo() == null - || targetInfo.getChooserTarget() == null) { - Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null"); - return; - } - targetList = new ArrayList<>(); - targetList.add(targetInfo.getDisplayResolveInfo()); + // TODO: migrate this condition to polymorphic calls on TargetInfo (maybe in some cases + // we can safely drop the `isSelectableTargetInfo()` condition and populate the bundle + // with any non-null values we find, regardless of the target type?) bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, targetInfo.getChooserTarget().getIntentExtras().getString( Intent.EXTRA_SHORTCUT_ID)); @@ -1735,20 +1741,9 @@ public class ChooserActivity extends ResolverActivity implements bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, targetInfo.getDisplayLabel().toString()); } - } else if (targetInfo.isMultiDisplayResolveInfo()) { - // For multiple targets, include info on all targets - MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; - targetList = mti.getTargets(); - } else { - targetList = new ArrayList(); - targetList.add((DisplayResolveInfo) targetInfo); } - bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, - targetList); - fragment.setArguments(bundle); + fragment.setArguments(bundle); fragment.show(getSupportFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); } diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index ae08ace2..b4e427a1 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -43,7 +43,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF void setStateFromBundle(Bundle b) { mMultiDisplayResolveInfo = (MultiDisplayResolveInfo) b.get(MULTI_DRI_KEY); - mTargetInfos = mMultiDisplayResolveInfo.getTargets(); + mTargetInfos = mMultiDisplayResolveInfo.getAllDisplayTargets(); mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); mParentWhich = b.getInt(WHICH_KEY); } diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 38a2759e..2de901cd 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -19,6 +19,9 @@ package com.android.intentresolver.chooser; import android.service.chooser.ChooserTarget; import android.text.TextUtils; +import java.util.ArrayList; +import java.util.Arrays; + /** * A TargetInfo for Direct Share. Includes a {@link ChooserTarget} representing the * Direct Share deep link into an application. @@ -30,6 +33,16 @@ public abstract class ChooserTargetInfo implements TargetInfo { return true; } + @Override + public ArrayList getAllDisplayTargets() { + // TODO: consider making this the default behavior for all `TargetInfo` implementations + // (if it's reasonable for `DisplayResolveInfo.getDisplayResolveInfo()` to return `this`). + if (getDisplayResolveInfo() == null) { + return new ArrayList<>(); + } + return new ArrayList<>(Arrays.asList(getDisplayResolveInfo())); + } + @Override public boolean isSimilar(TargetInfo other) { if (other == null) return false; diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 5e1b8cab..cd6828c7 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -35,6 +35,7 @@ import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; /** @@ -173,6 +174,11 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { return mSourceIntents; } + @Override + public ArrayList getAllDisplayTargets() { + return new ArrayList<>(Arrays.asList(this)); + } + public void addAlternateSourceIntent(Intent alt) { mSourceIntents.add(alt); } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index bad36077..29f00a35 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -30,8 +30,6 @@ import java.util.List; */ public class MultiDisplayResolveInfo extends DisplayResolveInfo { - /* TODO: hold as a generic `List` once we're unconstrained by the TODO - * regarding the return type of `#getTargets()`. */ ArrayList mTargetInfos = new ArrayList<>(); // Index of selected target @@ -71,7 +69,8 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { * TODO: provide as a generic {@code List} once {@link ChooserActivity} * stops requiring the signature to match that of the other "lists" it builds up. */ - public ArrayList getTargets() { + @Override + public ArrayList getAllDisplayTargets() { return mTargetInfos; } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 4842cd80..bde2fcf0 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -30,6 +30,7 @@ import android.service.chooser.ChooserTarget; import com.android.intentresolver.ResolverActivity; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -128,6 +129,28 @@ public interface TargetInfo { */ List getAllSourceIntents(); + /** + * @return the one or more {@link DisplayResolveInfo}s that this target represents in the UI. + * + * TODO: clarify the semantics of the {@link DisplayResolveInfo} branch of {@link TargetInfo}'s + * class hierarchy. Why is it that {@link MultiDisplayResolveInfo} can stand in for some + * "virtual" {@link DisplayResolveInfo} targets that aren't individually represented in the UI, + * but OTOH a {@link ChooserTargetInfo} (which doesn't inherit from {@link DisplayResolveInfo}) + * can't provide its own UI treatment, and instead needs us to reach into its composed-in + * info via {@link #getDisplayResolveInfo()}? It seems like {@link DisplayResolveInfo} may be + * required to populate views in our UI, while {@link ChooserTargetInfo} may carry some other + * metadata. For non-{@link ChooserTargetInfo} targets (e.g. in {@link ResolverActivity}) the + * "naked" {@link DisplayResolveInfo} might also be taken to provide some of this metadata, but + * this presents a denormalization hazard since the "UI info" ({@link DisplayResolveInfo}) that + * represents a {@link ChooserTargetInfo} might provide different values than its enclosing + * {@link ChooserTargetInfo} (as they both implement {@link TargetInfo}). We could try to + * address this by splitting {@link DisplayResolveInfo} into two types; one (which implements + * the same {@link TargetInfo} interface as {@link ChooserTargetInfo}) provides the previously- + * implicit "metadata", and the other provides only the UI treatment for a target of any type + * (taking over the respective methods that previously belonged to {@link TargetInfo}). + */ + ArrayList getAllDisplayTargets(); + /** * @return true if this target cannot be selected by the user */ diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 8494e296..4a5e1baf 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -122,7 +122,8 @@ class TargetInfoTest { assertThat(multiTargetInfo.getExtendedInfo()).isNull() - assertThat(multiTargetInfo.getTargets()).containsExactly(firstTargetInfo, secondTargetInfo) + assertThat(multiTargetInfo.getAllDisplayTargets()) + .containsExactly(firstTargetInfo, secondTargetInfo) assertThat(multiTargetInfo.hasSelected()).isFalse() assertThat(multiTargetInfo.getSelectedTarget()).isNull() -- cgit v1.2.3-59-g8ed1b From 198ff2693ebe41d8a664586bd9387f548a51b016 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 21 Oct 2022 14:25:51 -0400 Subject: Simplify ChooserActivityLogger. This interface only had one implementation outside of tests, and the test implementation was also unnecessarily complex. This CL consolidates the "real" implementation into the base interface, with injectable shims for the logging backend we'll use in tests. After this CL, integration tests that want to verify logging sequences can use a mock `ChooserActivityLogger` instead of a `FakeChooserActivityLogger`, and then make assertions about the (simpler) application-level events in that API. A new `ChooserActivityLoggerTest` covers the translation of these events into the appropriate representation for their `UiEventLogger` or `FrameworkStatsLog` backend, so integration tests no longer need to worry about that underlying detail. In the past, the only integration tests that exercised logging had been disabled in `ChooserActivityTest` due to flakes with indeterminate logging sequences to be addressed in b/211669337. Instead of attempting to patch the new (mock `ChooserActivityLogger`) style into test code that's already broken and disabled, I just deleted the relevant sections of those tests for now. Test: atest IntentResolverUnitTests Bug: 202167050, 211669337 Change-Id: Iaab551625284335469069bab8ee9a2d52fd955e6 --- .../android/intentresolver/ChooserActivity.java | 4 +- .../intentresolver/ChooserActivityLogger.java | 168 ++++++++++-- .../intentresolver/ChooserActivityLoggerImpl.java | 84 ------ .../intentresolver/ChooserActivityLoggerFake.java | 134 ---------- .../intentresolver/ChooserActivityLoggerTest.java | 239 +++++++++++++++++ .../ChooserActivityOverrideData.java | 2 +- .../UnbundledChooserActivityTest.java | 291 --------------------- 7 files changed, 390 insertions(+), 532 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java delete mode 100644 java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java create mode 100644 java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 26c7fbc9..31254de8 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2275,7 +2275,7 @@ public class ChooserActivity extends ResolverActivity implements protected ChooserActivityLogger getChooserActivityLogger() { if (mChooserActivityLogger == null) { - mChooserActivityLogger = new ChooserActivityLoggerImpl(); + mChooserActivityLogger = new ChooserActivityLogger(); } return mChooserActivityLogger; } @@ -4008,7 +4008,7 @@ public class ChooserActivity extends ResolverActivity implements @Override protected void maybeLogProfileChange() { - getChooserActivityLogger().logShareheetProfileChanged(); + getChooserActivityLogger().logSharesheetProfileChanged(); } private boolean shouldNearbyShareBeFirstInRankedRow() { diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 1daae01a..6d760b1a 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -19,45 +19,116 @@ package com.android.intentresolver; import android.content.Intent; import android.provider.MediaStore; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; import com.android.internal.util.FrameworkStatsLog; /** - * Interface for writing Sharesheet atoms to statsd log. + * Helper for writing Sharesheet atoms to statsd log. * @hide */ -public interface ChooserActivityLogger { +public class ChooserActivityLogger { + /** + * This shim is provided only for testing. In production, clients will only ever use a + * {@link DefaultFrameworkStatsLogger}. + */ + @VisibleForTesting + interface FrameworkStatsLogger { + /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */ + void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + String mimeType, + int numAppProvidedDirectTargets, + int numAppProvidedAppTargets, + boolean isWorkProfile, + int previewType, + int intentType); + + /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ + void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + int positionPicked, + boolean isPinned); + } + + private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); + + // A small per-notification ID, used for statsd logging. + // TODO: consider precomputing and storing as final. + private static InstanceIdSequence sInstanceIdSequence; + private InstanceId mInstanceId; + + private final UiEventLogger mUiEventLogger; + private final FrameworkStatsLogger mFrameworkStatsLogger; + + public ChooserActivityLogger() { + this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger()); + } + + @VisibleForTesting + ChooserActivityLogger(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger) { + mUiEventLogger = uiEventLogger; + mFrameworkStatsLogger = frameworkLogger; + } + /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ - void logShareStarted(int eventId, String packageName, String mimeType, int appProvidedDirect, - int appProvidedApp, boolean isWorkprofile, int previewType, String intent); + public void logShareStarted(int eventId, String packageName, String mimeType, + int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, + String intent) { + mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, + /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets = 5 */ appProvidedDirect, + /* num_app_provided_app_targets = 6 */ appProvidedApp, + /* is_workprofile = 7 */ isWorkprofile, + /* previewType = 8 */ typeFromPreviewInt(previewType), + /* intentType = 9 */ typeFromIntentString(intent)); + } /** Logs a UiEventReported event for the system sharesheet when the user selects a target. */ - void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned); + public void logShareTargetSelected(int targetType, String packageName, int positionPicked, + boolean isPinned) { + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + } /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ - default void logSharesheetTriggered() { + public void logSharesheetTriggered() { log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); } /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ - default void logSharesheetAppLoadComplete() { + public void logSharesheetAppLoadComplete() { log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet completing loading service targets. */ - default void logSharesheetDirectLoadComplete() { + public void logSharesheetDirectLoadComplete() { log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet timing out loading service targets. */ - default void logSharesheetDirectLoadTimeout() { + public void logSharesheetDirectLoadTimeout() { log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); } @@ -65,12 +136,12 @@ public interface ChooserActivityLogger { * Logs a UiEventReported event for the system sharesheet switching * between work and main profile. */ - default void logShareheetProfileChanged() { + public void logSharesheetProfileChanged() { log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); } /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ - default void logSharesheetExpansionChanged(boolean isCollapsed) { + public void logSharesheetExpansionChanged(boolean isCollapsed) { log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); } @@ -78,14 +149,14 @@ public interface ChooserActivityLogger { /** * Logs a UiEventReported event for the system sharesheet app share ranking timing out. */ - default void logSharesheetAppShareRankingTimeout() { + public void logSharesheetAppShareRankingTimeout() { log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); } /** * Logs a UiEventReported event for the system sharesheet when direct share row is empty. */ - default void logSharesheetEmptyDirectShareRow() { + public void logSharesheetEmptyDirectShareRow() { log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); } @@ -94,13 +165,26 @@ public interface ChooserActivityLogger { * @param event * @param instanceId */ - void log(UiEventLogger.UiEventEnum event, InstanceId instanceId); + private void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { + mUiEventLogger.logWithInstanceId( + event, + 0, + null, + instanceId); + } /** - * - * @return + * @return A unique {@link InstanceId} to join across events recorded by this logger instance. */ - InstanceId getInstanceId(); + private InstanceId getInstanceId() { + if (mInstanceId == null) { + if (sInstanceIdSequence == null) { + sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); + } + mInstanceId = sInstanceIdSequence.newInstanceId(); + } + return mInstanceId; + } /** * The UiEvent enums that this class can log. @@ -201,7 +285,7 @@ public interface ChooserActivityLogger { /** * Returns the enum used in sharesheet started atom to indicate what preview type was used. */ - default int typeFromPreviewInt(int previewType) { + private static int typeFromPreviewInt(int previewType) { switch(previewType) { case ChooserActivity.CONTENT_PREVIEW_IMAGE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; @@ -218,7 +302,7 @@ public interface ChooserActivityLogger { * Returns the enum used in sharesheet started atom to indicate what intent triggers the * ChooserActivity. */ - default int typeFromIntentString(String intent) { + private static int typeFromIntentString(String intent) { if (intent == null) { return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; } @@ -243,4 +327,48 @@ public interface ChooserActivityLogger { return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; } } + + private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + String mimeType, + int numAppProvidedDirectTargets, + int numAppProvidedAppTargets, + boolean isWorkProfile, + int previewType, + int intentType) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, + /* num_app_provided_app_targets */ numAppProvidedAppTargets, + /* is_workprofile */ isWorkProfile, + /* previewType = 8 */ previewType, + /* intentType = 9 */ intentType); + } + + @Override + public void write( + int frameworkEventId, + int appEventId, + String packageName, + int instanceId, + int positionPicked, + boolean isPinned) { + FrameworkStatsLog.write( + frameworkEventId, + /* event_id = 1 */ appEventId, + /* package_name = 2 */ packageName, + /* instance_id = 3 */ instanceId, + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + } + } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java b/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java deleted file mode 100644 index 08a345bc..00000000 --- a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2020 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.intentresolver; - -import com.android.internal.logging.InstanceId; -import com.android.internal.logging.InstanceIdSequence; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; -import com.android.internal.util.FrameworkStatsLog; - -/** - * Standard implementation of ChooserActivityLogger interface. - * @hide - */ -public class ChooserActivityLoggerImpl implements ChooserActivityLogger { - private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - - private UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); - // A small per-notification ID, used for statsd logging. - private InstanceId mInstanceId; - private static InstanceIdSequence sInstanceIdSequence; - - @Override - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - FrameworkStatsLog.write(FrameworkStatsLog.SHARESHEET_STARTED, - /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets = 5 */ appProvidedDirect, - /* num_app_provided_app_targets = 6 */ appProvidedApp, - /* is_workprofile = 7 */ isWorkprofile, - /* previewType = 8 */ typeFromPreviewInt(previewType), - /* intentType = 9 */ typeFromIntentString(intent)); - } - - @Override - public void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write(FrameworkStatsLog.RANKING_SELECTED, - /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), - /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - - @Override - public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { - mUiEventLogger.logWithInstanceId( - event, - 0, - null, - instanceId); - } - - @Override - public InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java deleted file mode 100644 index e4146cc5..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (C) 2020 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.intentresolver; - -import com.android.internal.logging.InstanceId; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.util.FrameworkStatsLog; - -import java.util.ArrayList; -import java.util.List; - -public class ChooserActivityLoggerFake implements ChooserActivityLogger { - static class CallRecord { - // shared fields between all logs - public int atomId; - public String packageName; - public InstanceId instanceId; - - // generic log field - public UiEventLogger.UiEventEnum event; - - // share started fields - public String mimeType; - public int appProvidedDirect; - public int appProvidedApp; - public boolean isWorkprofile; - public int previewType; - public String intent; - - // share completed fields - public int targetType; - public int positionPicked; - public boolean isPinned; - - CallRecord(int atomId, UiEventLogger.UiEventEnum eventId, - String packageName, InstanceId instanceId) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.event = eventId; - } - - CallRecord(int atomId, String packageName, InstanceId instanceId, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.mimeType = mimeType; - this.appProvidedDirect = appProvidedDirect; - this.appProvidedApp = appProvidedApp; - this.isWorkprofile = isWorkprofile; - this.previewType = previewType; - this.intent = intent; - } - - CallRecord(int atomId, String packageName, InstanceId instanceId, int targetType, - int positionPicked, boolean isPinned) { - this.atomId = atomId; - this.packageName = packageName; - this.instanceId = instanceId; - this.targetType = targetType; - this.positionPicked = positionPicked; - this.isPinned = isPinned; - } - - } - private List mCalls = new ArrayList<>(); - - public int numCalls() { - return mCalls.size(); - } - - List getCalls() { - return mCalls; - } - - CallRecord get(int index) { - return mCalls.get(index); - } - - UiEventLogger.UiEventEnum event(int index) { - return mCalls.get(index).event; - } - - public void removeCallsForUiEventsOfType(int uiEventType) { - mCalls.removeIf( - call -> - (call.atomId == FrameworkStatsLog.UI_EVENT_REPORTED) - && (call.event.getId() == uiEventType)); - } - - @Override - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { - mCalls.add(new CallRecord(FrameworkStatsLog.SHARESHEET_STARTED, packageName, - getInstanceId(), mimeType, appProvidedDirect, appProvidedApp, isWorkprofile, - previewType, intent)); - } - - @Override - public void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned) { - mCalls.add(new CallRecord(FrameworkStatsLog.RANKING_SELECTED, packageName, getInstanceId(), - SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), positionPicked, - isPinned)); - } - - @Override - public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { - mCalls.add(new CallRecord(FrameworkStatsLog.UI_EVENT_REPORTED, - event, "", instanceId)); - } - - @Override - public InstanceId getInstanceId() { - return InstanceId.fakeInstanceId(-1); - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java new file mode 100644 index 00000000..a93718fd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2020 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.intentresolver; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.AdditionalMatchers.gt; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.content.Intent; + +import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; +import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; +import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; +import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLogger.UiEventEnum; +import com.android.internal.util.FrameworkStatsLog; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class ChooserActivityLoggerTest { + @Mock private UiEventLogger mUiEventLog; + @Mock private FrameworkStatsLogger mFrameworkLog; + + private ChooserActivityLogger mChooserLogger; + + @Before + public void setUp() { + mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + } + + @After + public void tearDown() { + verifyNoMoreInteractions(mUiEventLog); + verifyNoMoreInteractions(mFrameworkLog); + } + + @Test + public void testLogShareStarted() { + final int eventId = -1; // Passed-in eventId is unused. TODO: remove from method signature. + final String packageName = "com.test.foo"; + final String mimeType = "text/plain"; + final int appProvidedDirectTargets = 123; + final int appProvidedAppTargets = 456; + final boolean workProfile = true; + final int previewType = ChooserActivity.CONTENT_PREVIEW_FILE; + final String intentAction = Intent.ACTION_SENDTO; + + mChooserLogger.logShareStarted( + eventId, + packageName, + mimeType, + appProvidedDirectTargets, + appProvidedAppTargets, + workProfile, + previewType, + intentAction); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.SHARESHEET_STARTED), + eq(SharesheetStartedEvent.SHARE_STARTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(mimeType), + eq(appProvidedDirectTargets), + eq(appProvidedAppTargets), + eq(workProfile), + eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO)); + } + + @Test + public void testLogShareTargetSelected() { + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(positionPicked), + eq(pinned)); + } + + @Test + public void testLogSharesheetTriggered() { + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppLoadComplete() { + mChooserLogger.logSharesheetAppLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetDirectLoadComplete() { + mChooserLogger.logSharesheetDirectLoadComplete(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetDirectLoadTimeout() { + mChooserLogger.logSharesheetDirectLoadTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetProfileChanged() { + mChooserLogger.logSharesheetProfileChanged(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_collapsed() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetExpansionChanged_expanded() { + mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any()); + } + + @Test + public void testLogSharesheetAppShareRankingTimeout() { + mChooserLogger.logSharesheetAppShareRankingTimeout(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT), + eq(0), + isNull(), + any()); + } + + @Test + public void testLogSharesheetEmptyDirectShareRow() { + mChooserLogger.logSharesheetEmptyDirectShareRow(); + verify(mUiEventLog).logWithInstanceId( + eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW), + eq(0), + isNull(), + any()); + } + + @Test + public void testDifferentLoggerInstancesUseDifferentInstanceIds() { + ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); + ChooserActivityLogger chooserLogger2 = + new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + chooserLogger2.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + + verify(mFrameworkLog, times(2)).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + int id1 = idIntCaptor.getAllValues().get(0); + int id2 = idIntCaptor.getAllValues().get(1); + + assertThat(id1).isGreaterThan(0); + assertThat(id2).isGreaterThan(0); + assertThat(id1).isNotEqualTo(id2); + } + + @Test + public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() { + ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); + + final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final String packageName = "com.test.foo"; + final int positionPicked = 123; + final boolean pinned = true; + + mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + verify(mFrameworkLog).write( + anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); + + mChooserLogger.logSharesheetTriggered(); + verify(mUiEventLog).logWithInstanceId( + any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture()); + + assertThat(idIntCaptor.getValue()).isGreaterThan(0); + assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 080f1e41..d1ca2b09 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -81,7 +81,7 @@ public class ChooserActivityOverrideData { resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); metricsLogger = mock(MetricsLogger.class); - chooserActivityLogger = new ChooserActivityLoggerFake(); + chooserActivityLogger = mock(ChooserActivityLogger.class); alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 5d600092..09ee8fc1 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -96,7 +96,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -820,49 +819,7 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_NEARBY_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_NEARBY_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @@ -891,49 +848,7 @@ public class UnbundledChooserActivityTest { onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("image/png")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(1)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_EDIT_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_EDIT_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @@ -2033,49 +1948,7 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_APP_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_APP_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @Test @Ignore @@ -2135,35 +2008,6 @@ public class UnbundledChooserActivityTest { onView(withText(name)) .perform(click()); waitForIdle(); - - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - assertThat(logger.numCalls(), is(6)); - // first one should be SHARESHEET_TRIGGERED uievent - assertThat(logger.get(0).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); - assertThat(logger.get(0).event.getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - // second one should be SHARESHEET_STARTED event - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - // third one should be SHARESHEET_APP_LOAD_COMPLETE uievent - assertThat(logger.get(2).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); - assertThat(logger.get(2).event.getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - // fourth and fifth are just artifacts of test set-up - // sixth one should be ranking atom with SHARESHEET_COPY_TARGET_SELECTED event - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId())); } @Test @Ignore @@ -2195,44 +2039,7 @@ public class UnbundledChooserActivityTest { assertThat("Chooser should have no direct targets", activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // SHARESHEET_EMPTY_DIRECT_SHARE_ROW: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // Next is just an artifact of test set-up: - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - assertThat(logger.numCalls(), is(5)); } @Ignore // b/220067877 @@ -2259,49 +2066,7 @@ public class UnbundledChooserActivityTest { onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is("text/plain")); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next are just artifacts of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); - - // SHARESHEET_COPY_TARGET_SELECTED: - assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); - assertThat(logger.get(5).targetType, - is(ChooserActivityLogger - .SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId())); - - // No more events. - assertThat(logger.numCalls(), is(6)); } @Test @Ignore("b/222124533") @@ -2324,63 +2089,7 @@ public class UnbundledChooserActivityTest { onView(withText(R.string.resolver_personal_tab)).perform(click()); waitForIdle(); - ChooserActivityLoggerFake logger = - (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - logger.removeCallsForUiEventsOfType( - ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); - - // SHARESHEET_TRIGGERED: - assertThat(logger.event(0).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); - - // SHARESHEET_STARTED: - assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); - assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); - assertThat(logger.get(1).mimeType, is(TEST_MIME_TYPE)); - assertThat(logger.get(1).packageName, is( - InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); - assertThat(logger.get(1).appProvidedApp, is(0)); - assertThat(logger.get(1).appProvidedDirect, is(0)); - assertThat(logger.get(1).isWorkprofile, is(false)); - assertThat(logger.get(1).previewType, is(3)); - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(2).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next is just an artifact of test set-up: - assertThat(logger.event(3).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // SHARESHEET_PROFILE_CHANGED: - assertThat(logger.event(4).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_PROFILE_CHANGED.getId())); - - // Repeat the loading steps in the new profile: - - // SHARESHEET_APP_LOAD_COMPLETE: - assertThat(logger.event(5).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); - - // Next is again an artifact of test set-up: - assertThat(logger.event(6).getId(), - is(ChooserActivityLogger - .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); - - // SHARESHEET_PROFILE_CHANGED: - assertThat(logger.event(7).getId(), - is(ChooserActivityLogger.SharesheetStandardEvent - .SHARESHEET_PROFILE_CHANGED.getId())); - - // No more events (this profile was already loaded). - assertThat(logger.numCalls(), is(8)); } @Test -- cgit v1.2.3-59-g8ed1b From 2a432f1aa08612593bacb3a3fcf254d39189c06b Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 24 Oct 2022 13:22:07 -0400 Subject: Expose ShortcutInfo & AppTarget from TargetInfo This allows `TargetInfo` clients to look up the same auxiliary source data that was already retained in `ChooserActivity`'s "cache" structures, but it removes the extra complexity of holding that data outside of the target itself only to need to look it back up again when we try to use the target later. (For now this CL leaves the auxiliary "cache" structures in place for one specific usage that's out-of-scope to refactor here, but it removes any other indeterminate subsequent uses). Incidentally, those "subsequent uses" that are removed were some of the only remaining places where we made calls to `TargetInfo.getChooserTarget()`, and we're attempting to burn down all the remaining calls to *that* API since `ChooserTarget` is deprecated. `SelectableTargetInfo` objects aleady retain the (nullable) `ShortcutInfo`, but for historical reasons it's been used as mutable state related to icon loading and could sometimes be reset to null. In order to expose the `ShortcutInfo` this CL makes it a `final` member and introduces an explicit state bit to replace the null-reset used in the past. This CL also makes some other minor cleanups in `SelectableTargetInfo` to clarify the immutability (precomputability) of some additional fields (to support unrelated upcoming CLs, while I happen to be making similar changes in here anyways). This brought attention to a likely bug where the target `mIsSuspended` bit was neglected in the implementation of `SelectableTargetInfo`'s "copy constructor" (fixed in this CL). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I1a18a1aea3acdded7c360311bf216f05fc75cb67 --- .../android/intentresolver/ChooserActivity.java | 33 +++-- .../android/intentresolver/ChooserListAdapter.java | 5 +- .../intentresolver/ShortcutSelectionLogic.java | 5 +- .../chooser/SelectableTargetInfo.java | 141 ++++++++++++++------- .../android/intentresolver/chooser/TargetInfo.java | 19 +++ .../intentresolver/ChooserListAdapterTest.kt | 1 + .../intentresolver/ShortcutSelectionLogicTest.kt | 9 +- .../UnbundledChooserActivityTest.java | 18 ++- .../intentresolver/chooser/TargetInfoTest.kt | 13 +- 9 files changed, 178 insertions(+), 66 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a73424bf..193956f9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -190,6 +190,11 @@ public class ChooserActivity extends ResolverActivity implements private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + // TODO: these data structures are for one-time use in shuttling data from where they're + // populated in `ShortcutToChooserTargetConverter` to where they're consumed in + // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. + // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their + // intermediate data, and then these members can be removed. private Map mDirectShareAppTargetCache; private Map mDirectShareShortcutInfoCache; @@ -494,7 +499,8 @@ public class ChooserActivity extends ResolverActivity implements adapterForUserHandle.addServiceResults( resultInfo.originalTarget, resultInfo.resultTargets, msg.arg1, - mDirectShareShortcutInfoCache); + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); } } } @@ -1683,7 +1689,8 @@ public class ChooserActivity extends ResolverActivity implements /* origTarget */ null, Lists.newArrayList(mCallerChooserTargets), TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ null); + /* directShareShortcutInfoCache */ null, + /* directShareAppTargetCache */ null); } } @@ -2164,12 +2171,15 @@ public class ChooserActivity extends ResolverActivity implements List surfacedTargetInfo = adapter.getSurfacedTargetInfo(); List targetIds = new ArrayList<>(); for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { - ChooserTarget chooserTarget = chooserTargetInfo.getChooserTarget(); - ComponentName componentName = chooserTarget.getComponentName(); - if (mDirectShareShortcutInfoCache.containsKey(chooserTarget)) { - String shortcutId = mDirectShareShortcutInfoCache.get(chooserTarget).getId(); + ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); + if (shortcutInfo != null) { + ComponentName componentName = + chooserTargetInfo.getChooserTarget().getComponentName(); targetIds.add(new AppTargetId( - String.format("%s/%s/%s", shortcutId, componentName.flattenToString(), + String.format( + "%s/%s/%s", + shortcutInfo.getId(), + componentName.flattenToString(), SHORTCUT_TARGET))); } } @@ -2186,13 +2196,12 @@ public class ChooserActivity extends ResolverActivity implements if (directShareAppPredictor == null) { return; } - ChooserTarget chooserTarget = targetInfo.getChooserTarget(); - AppTarget appTarget = null; - if (mDirectShareAppTargetCache != null) { - appTarget = mDirectShareAppTargetCache.get(chooserTarget); + if (!targetInfo.isChooserTargetInfo()) { + return; } - // This is a direct share click that was provided by the APS + AppTarget appTarget = targetInfo.getDirectShareAppTarget(); if (appTarget != null) { + // This is a direct share click that was provided by the APS directShareAppPredictor.notifyAppTargetEvent( new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH) .setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE) diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 85b50ab6..25b50625 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -22,6 +22,7 @@ import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_F import android.annotation.Nullable; import android.app.ActivityManager; import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -544,7 +545,8 @@ public class ChooserListAdapter extends ResolverListAdapter { @Nullable DisplayResolveInfo origTarget, List targets, @ChooserActivity.ShareTargetType int targetType, - Map directShareToShortcutInfos) { + Map directShareToShortcutInfos, + Map directShareToAppTargets) { // Avoid inserting any potentially late results. if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) { return; @@ -557,6 +559,7 @@ public class ChooserListAdapter extends ResolverListAdapter { targets, isShortcutResult, directShareToShortcutInfos, + directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), mSelectableTargetInfoCommunicator, mChooserListCommunicator.getMaxRankedTargets(), diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index a278baae..39187bdb 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -17,6 +17,7 @@ package com.android.intentresolver; import android.annotation.Nullable; +import android.app.prediction.AppTarget; import android.content.Context; import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; @@ -62,6 +63,7 @@ class ShortcutSelectionLogic { List targets, boolean isShortcutResult, Map directShareToShortcutInfos, + Map directShareToAppTargets, Context userContext, SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator, int maxRankedTargets, @@ -105,7 +107,8 @@ class ShortcutSelectionLogic { target, targetScore, mSelectableTargetInfoCommunicator, - shortcutInfo), + shortcutInfo, + directShareToAppTargets.get(target)), maxRankedTargets, serviceTargets); diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 506faa9d..3a3c3e64 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -18,6 +18,7 @@ package com.android.intentresolver.chooser; import android.annotation.Nullable; import android.app.Activity; +import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -61,17 +62,23 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final String mDisplayLabel; private final PackageManager mPm; private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; - @GuardedBy("this") - private ShortcutInfo mShortcutInfo; - private Drawable mBadgeIcon = null; - private CharSequence mBadgeContentDescription; - @GuardedBy("this") - private Drawable mDisplayIcon; + @Nullable + private final AppTarget mAppTarget; + @Nullable + private final ShortcutInfo mShortcutInfo; private final Intent mFillInIntent; private final int mFillInFlags; private final boolean mIsPinned; private final float mModifiedScore; - private boolean mIsSuspended = false; + private final boolean mIsSuspended; + private final Drawable mBadgeIcon; + private final CharSequence mBadgeContentDescription; + + @GuardedBy("this") + private Drawable mDisplayIcon; + + @GuardedBy("this") + private boolean mHasAttemptedIconLoad; /** Create a new {@link TargetInfo} instance representing a selectable target. */ public static TargetInfo newSelectableTargetInfo( @@ -80,14 +87,16 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { ChooserTarget chooserTarget, float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, - @Nullable ShortcutInfo shortcutInfo) { + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget) { return new SelectableTargetInfo( context, sourceInfo, chooserTarget, modifiedScore, selectableTargetInfoCommunicator, - shortcutInfo); + shortcutInfo, + appTarget); } private SelectableTargetInfo( @@ -96,7 +105,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { ChooserTarget chooserTarget, float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator, - @Nullable ShortcutInfo shortcutInfo) { + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget) { mContext = context; mSourceInfo = sourceInfo; mChooserTarget = chooserTarget; @@ -104,20 +114,17 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mPm = mContext.getPackageManager(); mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator; mShortcutInfo = shortcutInfo; + mAppTarget = appTarget; mIsPinned = shortcutInfo != null && shortcutInfo.isPinned(); - if (sourceInfo != null) { - final ResolveInfo ri = sourceInfo.getResolveInfo(); - if (ri != null) { - final ActivityInfo ai = ri.activityInfo; - if (ai != null && ai.applicationInfo != null) { - final PackageManager pm = mContext.getPackageManager(); - mBadgeIcon = pm.getApplicationIcon(ai.applicationInfo); - mBadgeContentDescription = pm.getApplicationLabel(ai.applicationInfo); - mIsSuspended = - (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - } - } - } + + final PackageManager pm = mContext.getPackageManager(); + final ApplicationInfo applicationInfo = getApplicationInfoFromSource(sourceInfo); + + mBadgeIcon = (applicationInfo == null) ? null : pm.getApplicationIcon(applicationInfo); + mBadgeContentDescription = + (applicationInfo == null) ? null : pm.getApplicationLabel(applicationInfo); + mIsSuspended = (applicationInfo != null) + && ((applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0); if (sourceInfo != null) { mBackupResolveInfo = null; @@ -141,9 +148,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mChooserTarget = other.mChooserTarget; mBadgeIcon = other.mBadgeIcon; mBadgeContentDescription = other.mBadgeContentDescription; + mShortcutInfo = other.mShortcutInfo; + mAppTarget = other.mAppTarget; + mIsSuspended = other.mIsSuspended; synchronized (other) { - mShortcutInfo = other.mShortcutInfo; mDisplayIcon = other.mDisplayIcon; + mHasAttemptedIconLoad = other.mHasAttemptedIconLoad; } mFillInIntent = fillInIntent; mFillInFlags = flags; @@ -158,12 +168,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return true; } - private String sanitizeDisplayLabel(CharSequence label) { - SpannableStringBuilder sb = new SpannableStringBuilder(label); - sb.clearSpans(); - return sb.toString(); - } - @Override public boolean isSuspended() { return mIsSuspended; @@ -180,24 +184,35 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { */ @Override public boolean loadIcon() { - ShortcutInfo shortcutInfo; - Drawable icon; synchronized (this) { - shortcutInfo = mShortcutInfo; - icon = mDisplayIcon; - } - boolean shouldLoadIcon = (icon == null) && (shortcutInfo != null); - if (shouldLoadIcon) { - icon = getChooserTargetIconDrawable(mChooserTarget, shortcutInfo); - if (icon == null) { + // TODO: evaluating these conditions while `synchronized` ensures that we get consistent + // reads between `mDisplayIcon` and `mHasAttemptedIconLoad`, but doesn't otherwise + // prevent races where two threads might check the conditions (in synchrony) and then + // both go on to load the icon (in parallel, even though one of the loads would be + // redundant, and even though we have no logic to decide which result to keep if they + // differ). This is probably a "safe optimization" in some cases, but our correctness + // can't rely on this eliding the duplicate load, and with a more careful design we + // could probably optimize it out in more cases (or else maybe we should get rid of + // this complexity altogether). + if ((mDisplayIcon != null) || (mShortcutInfo == null) || mHasAttemptedIconLoad) { return false; } - synchronized (this) { - mDisplayIcon = icon; - mShortcutInfo = null; - } } - return shouldLoadIcon; + + Drawable icon = getChooserTargetIconDrawable(mChooserTarget, mShortcutInfo); + if (icon == null) { + return false; + } + + synchronized (this) { + mDisplayIcon = icon; + // TODO: we only end up setting `mHasAttemptedIconLoad` if we were successful in loading + // a (non-null) display icon; in that case, our guard clause above will already + // early-return `false` regardless of `mHasAttemptedIconLoad`. This should be refined, + // or removed if we don't need the extra complexity (including the synchronizaiton?). + mHasAttemptedIconLoad = true; + } + return true; } private Drawable getChooserTargetIconDrawable(ChooserTarget target, @@ -348,6 +363,18 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mChooserTarget; } + @Override + @Nullable + public ShortcutInfo getDirectShareShortcutInfo() { + return mShortcutInfo; + } + + @Override + @Nullable + public AppTarget getDirectShareAppTarget() { + return mAppTarget; + } + @Override public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { return new SelectableTargetInfo(this, fillInIntent, flags); @@ -368,6 +395,32 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mIsPinned; } + @Nullable + private static ApplicationInfo getApplicationInfoFromSource( + @Nullable DisplayResolveInfo sourceInfo) { + if (sourceInfo == null) { + return null; + } + + final ResolveInfo resolveInfo = sourceInfo.getResolveInfo(); + if (resolveInfo == null) { + return null; + } + + final ActivityInfo activityInfo = resolveInfo.activityInfo; + if (activityInfo == null) { + return null; + } + + return activityInfo.applicationInfo; + } + + private static String sanitizeDisplayLabel(CharSequence label) { + SpannableStringBuilder sb = new SpannableStringBuilder(label); + sb.clearSpans(); + return sb.toString(); + } + /** * Necessary methods to communicate between {@link SelectableTargetInfo} * and {@link ResolverActivity} or {@link ChooserActivity}. diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index bde2fcf0..29c4cfc3 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -19,10 +19,12 @@ package com.android.intentresolver.chooser; import android.annotation.Nullable; import android.app.Activity; +import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; @@ -201,6 +203,23 @@ public interface TargetInfo { return null; } + /** + * @return the {@link ShortcutManager} data for any shortcut associated with this target. + */ + @Nullable + default ShortcutInfo getDirectShareShortcutInfo() { + return null; + } + + /** + * @return the {@link AppTarget} metadata if this target was sourced from App Prediction + * service, or null otherwise. + */ + @Nullable + default AppTarget getDirectShareAppTarget() { + return null; + } + /** * Attempt to load the display icon, if we have the info for one but it hasn't been loaded yet. * @return true if an icon may have been loaded as the result of this operation, potentially diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index f50d3b12..6ca7c5d1 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -127,6 +127,7 @@ class ChooserListAdapterTest { createChooserTarget(), 1f, selectableTargetInfoCommunicator, + null, null ) diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 16e0071e..f45d592f 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -66,6 +66,7 @@ class ShortcutSelectionLogicTest { /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 4, @@ -96,6 +97,7 @@ class ShortcutSelectionLogicTest { /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 4, @@ -126,6 +128,7 @@ class ShortcutSelectionLogicTest { /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 1, @@ -158,6 +161,7 @@ class ShortcutSelectionLogicTest { /* targets = */ listOf(pkgAsc1, pkgAsc2), /* isShortcutResult = */ true, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 4, @@ -169,6 +173,7 @@ class ShortcutSelectionLogicTest { /* targets = */ listOf(pkgBsc1, pkgBsc2), /* isShortcutResult = */ true, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 4, @@ -204,6 +209,7 @@ class ShortcutSelectionLogicTest { addFlags(ShortcutInfo.FLAG_PINNED) } ), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), /* mSelectableTargetInfoCommunicator = */ mock(), /* maxRankedTargets = */ 4, @@ -239,8 +245,9 @@ class ShortcutSelectionLogicTest { /* origTarget = */ null, /* origTargetScore = */ 0f, /* targets = */ listOf(sc1, sc2, sc3), - /* isShortcutResult = */ false /*isShortcutResult*/, + /* isShortcutResult = */ false, /* directShareToShortcutInfos = */ emptyMap(), + /* directShareToAppTargets = */ emptyMap(), /* userContext = */ context, /* mSelectableTargetInfoCommunicator = */ targetInfoCommunicator, /* maxRankedTargets = */ 4, diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 09ee8fc1..ee801101 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1317,7 +1317,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's @@ -1395,7 +1396,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's // necessary here because of the way the code is structured @@ -1477,7 +1479,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's // necessary here because of the way the code is structured @@ -1548,7 +1551,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's // necessary here because of the way the code is structured @@ -1655,7 +1659,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's // necessary here because of the way the code is structured @@ -1989,7 +1994,8 @@ public class UnbundledChooserActivityTest { /* resolveInfoPresentationGetter */ null), serviceTargets, TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos) + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); // Thread.sleep shouldn't be a thing in an integration test but it's // necessary here because of the way the code is structured diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 4a5e1baf..b6d0962b 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -16,8 +16,11 @@ package com.android.intentresolver.chooser +import android.app.prediction.AppTarget +import android.app.prediction.AppTargetId import android.content.Intent import android.content.pm.ShortcutInfo +import android.os.UserHandle import android.service.chooser.ChooserTarget import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.createChooserTarget @@ -56,6 +59,11 @@ class TargetInfoTest { "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") val selectableTargetInfoCommunicator: SelectableTargetInfoCommunicator = mock() val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.getComponentName().getPackageName(), + chooserTarget.getComponentName().getClassName(), + UserHandle.CURRENT) val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( context, @@ -63,11 +71,14 @@ class TargetInfoTest { chooserTarget, 0.1f, selectableTargetInfoCommunicator, - shortcutInfo) + shortcutInfo, + appTarget) assertThat(targetInfo.isSelectableTargetInfo()).isTrue() assertThat(targetInfo.isChooserTargetInfo()).isTrue() // From legacy inheritance model. assertThat(targetInfo.getDisplayResolveInfo()).isSameInstanceAs(displayInfo) assertThat(targetInfo.getChooserTarget()).isSameInstanceAs(chooserTarget) + assertThat(targetInfo.getDirectShareShortcutInfo()).isSameInstanceAs(shortcutInfo) + assertThat(targetInfo.getDirectShareAppTarget()).isSameInstanceAs(appTarget) // TODO: make more meaningful assertions about the behavior of a selectable target. } -- cgit v1.2.3-59-g8ed1b From 1c886d0a4769207b2febeb2fa3199a88fd5b9bb9 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 25 Oct 2022 16:10:59 -0700 Subject: Fix NPE Fix NPE when launching chooser with a caller-specified target. Enforce non-nullability of the ChooserListAdapter#addServiceResult map arguments. Attempt to invoke interface method 'java.lang.Object java.util.Map.get(java.lang.Object)' on a null object reference at com.android.intentresolver.ShortcutSelectionLogic.addServiceResults(ShortcutSelectionLogic.java:111) Test: manual testing with a caller-specified target Change-Id: I3ff85351d84545cb8a7537f703dc7cbc6214b272 --- java/src/com/android/intentresolver/ChooserActivity.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 193956f9..c88b2eb9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -138,6 +138,7 @@ import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -498,9 +499,10 @@ public class ChooserActivity extends ResolverActivity implements if (adapterForUserHandle != null) { adapterForUserHandle.addServiceResults( resultInfo.originalTarget, - resultInfo.resultTargets, msg.arg1, - mDirectShareShortcutInfoCache, - mDirectShareAppTargetCache); + resultInfo.resultTargets, + msg.arg1, + emptyIfNull(mDirectShareShortcutInfoCache), + emptyIfNull(mDirectShareAppTargetCache)); } } } @@ -1689,8 +1691,8 @@ public class ChooserActivity extends ResolverActivity implements /* origTarget */ null, Lists.newArrayList(mCallerChooserTargets), TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ null, - /* directShareAppTargetCache */ null); + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); } } @@ -4022,4 +4024,8 @@ public class ChooserActivity extends ResolverActivity implements private boolean shouldNearbyShareBeIncludedAsActionButton() { return !shouldNearbyShareBeFirstInRankedRow(); } + + private static Map emptyIfNull(@Nullable Map map) { + return map == null ? Collections.emptyMap() : map; + } } -- cgit v1.2.3-59-g8ed1b From 8d51b6f18d47cc6d019529a6b7f1938936d14b4a Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 26 Oct 2022 16:03:10 -0400 Subject: Clean up ChooserActivity's per-profile bookkeeping This is mostly taken from ag/20236439, reimagined for not having extracted the new `ShortcutLoader` component yet. The most notable change is the decoupling of ChooserListAdapter from any AppPredictor instance (or its callback). The previous model gave us a mechanism for associating each of our user IDs to their respective AppPredictor/Callback instances, but otherwise wasn't necessary to support any usage in ChooserListAdapter. OTOH it fell short of actually assigning any responsibility to ChooserListAdapter as the "owner" of these instances, since they're created and retained externally (in ChooserActivity), by the same component that uses them and is ultimately responsible for requesting their destruction (according to its own mechanism for getting back to a particular handle/adapter/AppPredictor/callback "tuple"). Not only is this unnecessarily complex, it also creates a denormalization hazard since ChooserListAdapter resetting its own AppPredictor reference to null after destruction doesn't reset ChooserActivity's reference, which could still be used (and would throw on the use-after-free). The CL also includes some minor cleanups that are helpful to set up for ag/20236439. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ia22c425fcd34627fb67a3ad2543b6d7ac51b5dea --- .../android/intentresolver/ChooserActivity.java | 116 ++++++++++++++------- .../android/intentresolver/ChooserListAdapter.java | 34 ++---- 2 files changed, 88 insertions(+), 62 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c88b2eb9..6455e4d1 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -74,6 +74,7 @@ import android.os.Message; import android.os.Parcelable; import android.os.PatternMatcher; import android.os.ResultReceiver; +import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageManager; @@ -87,6 +88,7 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.util.HashedStringCache; import android.util.Log; +import android.util.Pair; import android.util.PluralsMessageFormatter; import android.util.Size; import android.util.Slog; @@ -270,8 +272,6 @@ public class ChooserActivity extends ResolverActivity implements private long mChooserShownTime; protected boolean mIsSuccessfullySelected; - private long mQueriedSharingShortcutsTimeMs; - private int mCurrAvailableWidth = 0; private Insets mLastAppliedInsets = null; private int mLastNumberOfChildren = -1; @@ -313,7 +313,7 @@ public class ChooserActivity extends ResolverActivity implements private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = new ShortcutToChooserTargetConverter(); - private final SparseArray mProfileAppPredictors = new SparseArray<>(); + private final SparseArray mProfileRecords = new SparseArray<>(); private class ContentPreviewCoordinator { private static final int IMAGE_FADE_IN_MILLIS = 150; @@ -483,14 +483,18 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "LIST_VIEW_UPDATE_MESSAGE; "); } - UserHandle userHandle = (UserHandle) msg.obj; - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle) + mChooserMultiProfilePagerAdapter + .getListAdapterForUserHandle((UserHandle) msg.obj) .refreshListView(); break; case SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS: if (DEBUG) Log.d(TAG, "SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS"); - final ServiceResultInfo[] resultInfos = (ServiceResultInfo[]) msg.obj; + final Pair args = + (Pair) msg.obj; + final UserHandle userHandle = args.first; + final ServiceResultInfo[] resultInfos = args.second; + for (ServiceResultInfo resultInfo : resultInfos) { if (resultInfo.resultTargets != null) { ChooserListAdapter adapterForUserHandle = @@ -508,7 +512,9 @@ public class ChooserActivity extends ResolverActivity implements } logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER); + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + userHandle); + sendVoiceChoicesIfNeeded(); getChooserActivityLogger().logSharesheetDirectLoadComplete(); @@ -660,7 +666,7 @@ public class ChooserActivity extends ResolverActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); - createAppPredictors( + createProfileRecords( new AppPredictorFactory( this, target.getStringExtra(Intent.EXTRA_TEXT), @@ -738,31 +744,35 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - private void createAppPredictors(AppPredictorFactory factory) { + private void createProfileRecords(AppPredictorFactory factory) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); - createAppPredictorForProfile(mainUserHandle, factory); + createProfileRecord(mainUserHandle, factory); + UserHandle workUserHandle = getWorkProfileUserHandle(); if (workUserHandle != null) { - createAppPredictorForProfile(workUserHandle, factory); + createProfileRecord(workUserHandle, factory); } } - private void createAppPredictorForProfile(UserHandle userHandle, AppPredictorFactory factory) { + private void createProfileRecord(UserHandle userHandle, AppPredictorFactory factory) { AppPredictor appPredictor = factory.create(userHandle); - if (appPredictor != null) { - mProfileAppPredictors.put(userHandle.getIdentifier(), appPredictor); - } + mProfileRecords.put( + userHandle.getIdentifier(), new ProfileRecord(appPredictor)); + } + + @Nullable + private ProfileRecord getProfileRecord(UserHandle userHandle) { + return mProfileRecords.get(userHandle.getIdentifier(), null); } - private AppPredictor setupAppPredictorForUser(UserHandle userHandle, - AppPredictor.Callback appPredictorCallback) { + private void setupAppPredictorForUser( + UserHandle userHandle, AppPredictor.Callback appPredictorCallback) { AppPredictor appPredictor = getAppPredictor(userHandle); if (appPredictor == null) { - return null; + return; } mDirectShareAppTargetCache = new HashMap<>(); appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); - return appPredictor; } private AppPredictor.Callback createAppPredictorCallback( @@ -1638,11 +1648,14 @@ public class ChooserActivity extends ResolverActivity implements if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); - mChooserMultiProfilePagerAdapter.getActiveListAdapter().destroyAppPredictor(); - if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) { - mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor(); + destroyProfileRecords(); + } + + private void destroyProfileRecords() { + for (int i = 0; i < mProfileRecords.size(); ++i) { + mProfileRecords.valueAt(i).destroy(); } - mProfileAppPredictors.clear(); + mProfileRecords.clear(); } @Override // ResolverListCommunicator @@ -1987,12 +2000,17 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected void queryDirectShareTargets( ChooserListAdapter adapter, boolean skipAppPredictionService) { - mQueriedSharingShortcutsTimeMs = System.currentTimeMillis(); + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + if (record == null) { + return; + } + + record.loadingStartTime = SystemClock.elapsedRealtime(); + UserHandle userHandle = adapter.getUserHandle(); if (!skipAppPredictionService) { - AppPredictor appPredictor = getAppPredictor(userHandle); - if (appPredictor != null) { - appPredictor.requestPredictionUpdate(); + if (record.appPredictor != null) { + record.appPredictor.requestPredictionUpdate(); return; } } @@ -2062,8 +2080,7 @@ public class ChooserActivity extends ResolverActivity implements // for direct share targets. After ShareSheet is refactored we should use the // ShareShortcutInfos directly. List resultRecords = new ArrayList<>(); - for (int i = 0; i < chooserListAdapter.getDisplayResolveInfoCount(); i++) { - DisplayResolveInfo displayResolveInfo = chooserListAdapter.getDisplayResolveInfo(i); + for (DisplayResolveInfo displayResolveInfo : chooserListAdapter.getDisplayResolveInfos()) { List matchingShortcuts = filterShortcutsByTargetComponentName( resultList, displayResolveInfo.getResolvedComponentName()); @@ -2085,7 +2102,7 @@ public class ChooserActivity extends ResolverActivity implements } sendShortcutManagerShareTargetResults( - shortcutType, resultRecords.toArray(new ServiceResultInfo[0])); + userHandle, shortcutType, resultRecords.toArray(new ServiceResultInfo[0])); } private List filterShortcutsByTargetComponentName( @@ -2100,10 +2117,10 @@ public class ChooserActivity extends ResolverActivity implements } private void sendShortcutManagerShareTargetResults( - int shortcutType, ServiceResultInfo[] results) { + UserHandle userHandle, int shortcutType, ServiceResultInfo[] results) { final Message msg = Message.obtain(); msg.what = ChooserHandler.SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS; - msg.obj = results; + msg.obj = Pair.create(userHandle, results); msg.arg1 = shortcutType; mChooserHandler.sendMessage(msg); } @@ -2126,8 +2143,14 @@ public class ChooserActivity extends ResolverActivity implements return false; } - private void logDirectShareTargetReceived(int logCategory) { - final int apiLatency = (int) (System.currentTimeMillis() - mQueriedSharingShortcutsTimeMs); + private void logDirectShareTargetReceived(int logCategory, UserHandle forUser) { + ProfileRecord profileRecord = getProfileRecord(forUser); + if (profileRecord == null) { + return; + } + + final int apiLatency = + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime); getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); } @@ -2213,7 +2236,8 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private AppPredictor getAppPredictor(UserHandle userHandle) { - return mProfileAppPredictors.get(userHandle.getIdentifier(), null); + ProfileRecord record = getProfileRecord(userHandle); + return (record == null) ? null : record.appPredictor; } void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { @@ -2333,9 +2357,7 @@ public class ChooserActivity extends ResolverActivity implements if (!ActivityManager.isLowRamDeviceStatic()) { AppPredictor.Callback appPredictorCallback = createAppPredictorCallback(chooserListAdapter); - AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback); - chooserListAdapter.setAppPredictor(appPredictor); - chooserListAdapter.setAppPredictorCallback(appPredictorCallback); + setupAppPredictorForUser(userHandle, appPredictorCallback); } return new ChooserGridAdapter(chooserListAdapter); } @@ -4028,4 +4050,22 @@ public class ChooserActivity extends ResolverActivity implements private static Map emptyIfNull(@Nullable Map map) { return map == null ? Collections.emptyMap() : map; } + + private static class ProfileRecord { + /** The {@link AppPredictor} for this profile, if any. */ + @Nullable + public final AppPredictor appPredictor; + + public long loadingStartTime; + + ProfileRecord(@Nullable AppPredictor appPredictor) { + this.appPredictor = appPredictor; + } + + public void destroy() { + if (appPredictor != null) { + appPredictor.destroy(); + } + } + } } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 25b50625..2443659f 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -21,7 +21,6 @@ import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_F import android.annotation.Nullable; import android.app.ActivityManager; -import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; @@ -95,8 +94,6 @@ public class ChooserListAdapter extends ResolverListAdapter { // Sorted list of DisplayResolveInfos for the alphabetical app section. private List mSortedList = new ArrayList<>(); - private AppPredictor mAppPredictor; - private AppPredictor.Callback mAppPredictorCallback; private final ShortcutSelectionLogic mShortcutSelectionLogic; @@ -218,10 +215,6 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - AppPredictor getAppPredictor() { - return mAppPredictor; - } - @Override public void handlePackagesChanged() { if (DEBUG) { @@ -435,6 +428,16 @@ public class ChooserListAdapter extends ResolverListAdapter { return Math.min(spacesAvailable, super.getCount()); } + /** Get all the {@link DisplayResolveInfo} data for our targets. */ + public DisplayResolveInfo[] getDisplayResolveInfos() { + int size = getDisplayResolveInfoCount(); + DisplayResolveInfo[] resolvedTargets = new DisplayResolveInfo[size]; + for (int i = 0; i < size; i++) { + resolvedTargets[i] = getDisplayResolveInfo(i); + } + return resolvedTargets; + } + public int getPositionTargetType(int position) { int offset = 0; @@ -469,7 +472,6 @@ public class ChooserListAdapter extends ResolverListAdapter { return targetInfoForPosition(position, true); } - /** * Find target info for a given position. * Since ChooserActivity displays several sections of content, determine which @@ -642,22 +644,6 @@ public class ChooserListAdapter extends ResolverListAdapter { }; } - public void setAppPredictor(AppPredictor appPredictor) { - mAppPredictor = appPredictor; - } - - public void setAppPredictorCallback(AppPredictor.Callback appPredictorCallback) { - mAppPredictorCallback = appPredictorCallback; - } - - public void destroyAppPredictor() { - if (getAppPredictor() != null) { - getAppPredictor().unregisterPredictionUpdates(mAppPredictorCallback); - getAppPredictor().destroy(); - setAppPredictor(null); - } - } - /** * Necessary methods to communicate between {@link ChooserListAdapter} * and {@link ChooserActivity}. -- cgit v1.2.3-59-g8ed1b From 9bcaf18b0fda7adb1a93c5d170e390bf3b7ce0e3 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 24 Oct 2022 13:56:21 +0000 Subject: Remove experiment code to promote nearby as 1st target - Verified that we don't expect to do this experiment in U, nothing else was using this code so we can simplify. - Fix NPE that was hidden in the code (see bug). - Slight change to the excluded components behavior where the entire array given for EXTRA_EXCLUDE_COMPONENTS must be of type ComponentName[] otherwise it'll be ignored after logging an error. Relevant prior CLs: - ag/15527538 added this functionality (zzhen) - ag/17871086 added direct share pinning and modified some of these methods (songhu) Bug: 254895117 Test: atest AbstractResolverComparatorTest Test: atest CtsSharesheetDeviceTest (with flag enabled) Test: Manual verification that nearby component still hidden. Change-Id: I0306eb2547a2f60823d7c9d77d86e743d432fefc --- .../intentresolver/AbstractResolverComparator.java | 5 -- .../android/intentresolver/ChooserActivity.java | 99 +++++----------------- .../android/intentresolver/ResolverActivity.java | 9 -- .../intentresolver/ResolverListController.java | 10 --- .../AbstractResolverComparatorTest.java | 17 ---- 5 files changed, 22 insertions(+), 118 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractResolverComparator.java b/java/src/com/android/intentresolver/AbstractResolverComparator.java index 6f802876..07dcd664 100644 --- a/java/src/com/android/intentresolver/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/AbstractResolverComparator.java @@ -161,11 +161,6 @@ public abstract class AbstractResolverComparator implements Comparator mFilteredComponentNames; private Intent mReferrerFillInIntent; @@ -619,32 +613,22 @@ public class ChooserActivity extends ResolverActivity implements mPinnedSharedPrefs = getPinnedSharedPrefs(this); - pa = intent.getParcelableArrayExtra(Intent.EXTRA_EXCLUDE_COMPONENTS); - - - // Exclude out Nearby from main list if chip is present, to avoid duplication - ComponentName nearbySharingComponent = getNearbySharingComponent(); - boolean shouldFilterNearby = !shouldNearbyShareBeFirstInRankedRow() - && nearbySharingComponent != null; - - if (pa != null) { - ComponentName[] names = new ComponentName[pa.length + (shouldFilterNearby ? 1 : 0)]; - for (int i = 0; i < pa.length; i++) { - if (!(pa[i] instanceof ComponentName)) { - Log.w(TAG, "Filtered component #" + i + " not a ComponentName: " + pa[i]); - names = null; - break; - } - names[i] = (ComponentName) pa[i]; - } - if (shouldFilterNearby) { - names[names.length - 1] = nearbySharingComponent; + mFilteredComponentNames = new ArrayList<>(); + try { + ComponentName[] exclodedComponents = intent.getParcelableArrayExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, + ComponentName.class); + if (exclodedComponents != null) { + Collections.addAll(mFilteredComponentNames, exclodedComponents); } + } catch (ClassCastException e) { + Log.e(TAG, "Excluded components must be of type ComponentName[]", e); + } - mFilteredComponentNames = names; - } else if (shouldFilterNearby) { - mFilteredComponentNames = new ComponentName[1]; - mFilteredComponentNames[0] = nearbySharingComponent; + // Exclude Nearby from main list if chip is present, to avoid duplication + ComponentName nearby = getNearbySharingComponent(); + if (nearby != null) { + mFilteredComponentNames.add(nearby); } pa = intent.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_TARGETS); @@ -1325,9 +1309,7 @@ public class ChooserActivity extends ResolverActivity implements final ViewGroup actionRow = (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } + addActionButton(actionRow, createNearbyButton(targetIntent)); CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (sharingText == null) { @@ -1378,9 +1360,7 @@ public class ChooserActivity extends ResolverActivity implements final ViewGroup actionRow = (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); //TODO: addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } + addActionButton(actionRow, createNearbyButton(targetIntent)); addActionButton(actionRow, createEditButton(targetIntent)); mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); @@ -1500,9 +1480,7 @@ public class ChooserActivity extends ResolverActivity implements final ViewGroup actionRow = (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); //TODO(b/120417119): addActionButton(actionRow, createCopyButton()); - if (shouldNearbyShareBeIncludedAsActionButton()) { - addActionButton(actionRow, createNearbyButton(targetIntent)); - } + addActionButton(actionRow, createNearbyButton(targetIntent)); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -2324,27 +2302,13 @@ public class ChooserActivity extends ResolverActivity implements @Override boolean isComponentFiltered(ComponentName name) { - if (mFilteredComponentNames == null) { - return false; - } - for (ComponentName filteredComponentName : mFilteredComponentNames) { - if (name.equals(filteredComponentName)) { - return true; - } - } - return false; + return mFilteredComponentNames != null && mFilteredComponentNames.contains(name); } @Override public boolean isComponentPinned(ComponentName name) { return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); } - - @Override - public boolean isFixedAtTop(ComponentName name) { - return name != null && name.equals(getNearbySharingComponent()) - && shouldNearbyShareBeFirstInRankedRow(); - } } @VisibleForTesting @@ -2899,8 +2863,8 @@ public class ChooserActivity extends ResolverActivity implements .targetInfoForPosition(mListPosition, /* filtered */ true); // This should always be the case for ItemViewHolder, check for validity - if (ti.isDisplayResolveInfo() && shouldShowTargetDetails(ti)) { - showTargetDetails((DisplayResolveInfo) ti); + if (ti.isDisplayResolveInfo()) { + showTargetDetails(ti); } return true; }); @@ -2908,15 +2872,6 @@ public class ChooserActivity extends ResolverActivity implements } } - private boolean shouldShowTargetDetails(TargetInfo ti) { - ComponentName nearbyShare = getNearbySharingComponent(); - // Suppress target details for nearby share to hide pin/unpin action - boolean isNearbyShare = nearbyShare != null && nearbyShare.equals( - ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow(); - return ti.isSelectableTargetInfo() - || (ti.isDisplayResolveInfo() && !isNearbyShare); - } - /** * Add a footer to the list, to support scrolling behavior below the navbar. */ @@ -3279,9 +3234,7 @@ public class ChooserActivity extends ResolverActivity implements v.setOnLongClickListener(v1 -> { TargetInfo ti = mChooserListAdapter.targetInfoForPosition( holder.getItemIndex(column), true); - if (shouldShowTargetDetails(ti)) { - showTargetDetails(ti); - } + showTargetDetails(ti); return true; }); @@ -4039,14 +3992,6 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetProfileChanged(); } - private boolean shouldNearbyShareBeFirstInRankedRow() { - return ActivityManager.isLowRamDeviceStatic() && mIsNearbyShareFirstTargetInRankedApp; - } - - private boolean shouldNearbyShareBeIncludedAsActionButton() { - return !shouldNearbyShareBeFirstInRankedRow(); - } - private static Map emptyIfNull(@Nullable Map map) { return map == null ? Collections.emptyMap() : map; } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 1d57b9f2..19251490 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -2140,7 +2140,6 @@ public class ResolverActivity extends FragmentActivity implements private final List mIntents = new ArrayList<>(); private final List mResolveInfos = new ArrayList<>(); private boolean mPinned; - private boolean mFixedAtTop; public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { this.name = name; @@ -2189,14 +2188,6 @@ public class ResolverActivity extends FragmentActivity implements public void setPinned(boolean pinned) { mPinned = pinned; } - - public boolean isFixedAtTop() { - return mFixedAtTop; - } - - public void setFixedAtTop(boolean isFixedAtTop) { - mFixedAtTop = isFixedAtTop; - } } class ItemClickListener implements AdapterView.OnItemClickListener, diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index ff616ce0..6169c032 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -32,7 +32,6 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.chooser.DisplayResolveInfo; - import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; @@ -187,7 +186,6 @@ public class ResolverListController { final ResolverActivity.ResolvedComponentInfo rci = new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo); rci.setPinned(isComponentPinned(name)); - rci.setFixedAtTop(isFixedAtTop(name)); into.add(rci); } } @@ -202,14 +200,6 @@ public class ResolverListController { return false; } - /** - * Whether this component is fixed at top in the ranked apps list. Always false for Resolver; - * overridden in Chooser. - */ - public boolean isFixedAtTop(ComponentName name) { - return false; - } - // Filter out any activities that the launched uid does not have permission for. // To preserve the inputList, optionally will return the original list if any modification has // been made. diff --git a/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java index 66e6f5b2..36058a6c 100644 --- a/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java @@ -33,23 +33,6 @@ import java.util.List; public class AbstractResolverComparatorTest { - @Test - public void testPositionFixed() { - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), new ResolveInfo() - ); - r1.setFixedAtTop(true); - - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() - ); - r2.setPinned(true); - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); - assertEquals("FixedAtTop ranks over pinned", -1, comparator.compare(r1, r2)); - assertEquals("Pinned ranks under fixedAtTop", 1, comparator.compare(r2, r1)); - } - @Test public void testPinned() { ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( -- cgit v1.2.3-59-g8ed1b From 13c54bc6fc60fb1d4330f75824ed756157bdff71 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 20 Oct 2022 15:10:31 -0400 Subject: Chooser fragments shouldn't save instance state. 1. I can't think of any way we could possibly trigger the fragment's load-from-saved-state flow since Sharesheet isn't in history. 2. If this flow was ever triggered for the "stacked app" fragment we would crash. Our first step in restoring from a `Bundle` is to make an unsafe downcast to `MultiDisplayResolveInfo` and invoke one of its subclass methods, but `MultiDisplayResolveInfo` doesn't actually implement `Parcelable` correctly; it inherits the relationship via `DisplayResolveInfo`, including its `Creator`, so any attempt to restore the parceled target would get a concrete `DisplayResolveInfo`, slicing off any data specific to its old multi-target type (and, of course, leaving an object that can't use any of the subclass methods). 3. Support for this flow seems to be the only reason that `DisplayResolveInfo` implements `Parcelable` at all, so as part of go/chooser-targetinfo-cleanup we should prune this (broken and dead) code and then go on to remove all of that complexity from the `TargetInfo` API. Test: manual & presubmit Bug: 202167050 Change-Id: Ida3b74edf975e3c1353e757b37e4cfadf716d5c3 --- .../android/intentresolver/ChooserActivity.java | 64 +++++------- .../ChooserStackedAppDialogFragment.java | 64 +++++++----- .../ChooserTargetActionsDialogFragment.java | 107 ++++++++++++--------- .../intentresolver/chooser/DisplayResolveInfo.java | 41 +------- 4 files changed, 125 insertions(+), 151 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a73424bf..b0e2450b 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -271,8 +271,6 @@ public class ChooserActivity extends ResolverActivity implements private int mLastNumberOfChildren = -1; private int mMaxTargetsPerRow = 1; - private static final String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment"; - private static final int MAX_LOG_RANK_POSITION = 12; private static final int MAX_EXTRA_INITIAL_INTENTS = 2; @@ -1718,33 +1716,28 @@ public class ChooserActivity extends ResolverActivity implements return; } - ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(); - Bundle bundle = new Bundle(); - - bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, - new ArrayList<>(targetList)); - - if (targetInfo.isSelectableTargetInfo()) { - // TODO: migrate this condition to polymorphic calls on TargetInfo (maybe in some cases - // we can safely drop the `isSelectableTargetInfo()` condition and populate the bundle - // with any non-null values we find, regardless of the target type?) - bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, - targetInfo.getChooserTarget().getIntentExtras().getString( - Intent.EXTRA_SHORTCUT_ID)); - bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, - targetInfo.isPinned()); - bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, - getTargetIntentFilter()); - if (targetInfo.getDisplayLabel() != null) { - bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, - targetInfo.getDisplayLabel().toString()); - } - } - - fragment.setArguments(bundle); - fragment.show(getSupportFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); + // TODO: implement these type-conditioned behaviors polymorphically, and consider moving + // the logic into `ChooserTargetActionsDialogFragment.show()`. + boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); + IntentFilter intentFilter = + targetInfo.isSelectableTargetInfo() ? getTargetIntentFilter() : null; + String shortcutTitle = targetInfo.isSelectableTargetInfo() + ? targetInfo.getDisplayLabel().toString() : null; + String shortcutIdKey = targetInfo.isSelectableTargetInfo() + ? targetInfo + .getChooserTarget() + .getIntentExtras() + .getString(Intent.EXTRA_SHORTCUT_ID) + : null; + + ChooserTargetActionsDialogFragment.show( + getSupportFragmentManager(), + targetList, + mChooserMultiProfilePagerAdapter.getCurrentUserHandle(), + shortcutIdKey, + shortcutTitle, + isShortcutPinned, + intentFilter); } private void modifyTargetIntent(Intent in) { @@ -1801,16 +1794,11 @@ public class ChooserActivity extends ResolverActivity implements if (targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { - ChooserStackedAppDialogFragment f = new ChooserStackedAppDialogFragment(); - Bundle b = new Bundle(); - b.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + ChooserStackedAppDialogFragment.show( + getSupportFragmentManager(), + mti, + which, mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - b.putObject(ChooserStackedAppDialogFragment.MULTI_DRI_KEY, - mti); - b.putInt(ChooserStackedAppDialogFragment.WHICH_KEY, which); - f.setArguments(b); - - f.show(getSupportFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); return; } } diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index b4e427a1..2cfceeae 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -20,9 +20,10 @@ package com.android.intentresolver; import android.content.DialogInterface; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; -import android.os.Bundle; import android.os.UserHandle; +import androidx.fragment.app.FragmentManager; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -30,29 +31,39 @@ import com.android.intentresolver.chooser.MultiDisplayResolveInfo; * Shows individual actions for a "stacked" app target - such as an app with multiple posting * streams represented in the Sharesheet. */ -public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment - implements DialogInterface.OnClickListener { - - static final String WHICH_KEY = "which_key"; - static final String MULTI_DRI_KEY = "multi_dri_key"; - - private MultiDisplayResolveInfo mMultiDisplayResolveInfo; - private int mParentWhich; - - public ChooserStackedAppDialogFragment() {} +public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment { - void setStateFromBundle(Bundle b) { - mMultiDisplayResolveInfo = (MultiDisplayResolveInfo) b.get(MULTI_DRI_KEY); - mTargetInfos = mMultiDisplayResolveInfo.getAllDisplayTargets(); - mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); - mParentWhich = b.getInt(WHICH_KEY); + /** + * Display a fragment for the user to select one of the members of a target "stack." + * @param stackedTarget The display info for the full stack to select within. + * @param stackedTargetParentWhich The "which" value that the {@link ChooserActivity} uses to + * identify the {@code stackedTarget} as presented in the chooser menu UI. If the user selects + * a target in this fragment, the selection will be saved in the {@link MultiDisplayResolveInfo} + * and then the {@link ChooserActivity} will receive a {@code #startSelected()} callback using + * this "which" value to identify the stack that's now unambiguously resolved. + * @param userHandle + * + * TODO: consider taking a client-provided callback instead of {@code stackedTargetParentWhich} + * to avoid coupling with {@link ChooserActivity}'s mechanism for handling the selection. + */ + public static void show( + FragmentManager fragmentManager, + MultiDisplayResolveInfo stackedTarget, + int stackedTargetParentWhich, + UserHandle userHandle) { + ChooserStackedAppDialogFragment fragment = new ChooserStackedAppDialogFragment( + stackedTarget, stackedTargetParentWhich, userHandle); + fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG); } + private final MultiDisplayResolveInfo mMultiDisplayResolveInfo; + private final int mParentWhich; + @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt(WHICH_KEY, mParentWhich); - outState.putParcelable(MULTI_DRI_KEY, mMultiDisplayResolveInfo); + public void onClick(DialogInterface dialog, int which) { + mMultiDisplayResolveInfo.setSelected(which); + ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + dismiss(); } @Override @@ -63,15 +74,16 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF @Override protected Drawable getItemIcon(DisplayResolveInfo dri) { - // Show no icon for the group disambig dialog, null hides the imageview return null; } - @Override - public void onClick(DialogInterface dialog, int which) { - mMultiDisplayResolveInfo.setSelected(which); - ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); - dismiss(); + private ChooserStackedAppDialogFragment( + MultiDisplayResolveInfo stackedTarget, + int stackedTargetParentWhich, + UserHandle userHandle) { + super(stackedTarget.getAllDisplayTargets(), userHandle); + mMultiDisplayResolveInfo = stackedTarget; + mParentWhich = stackedTargetParentWhich; } } diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index 61b54fa9..f4d4a6d1 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -49,11 +49,11 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.chooser.DisplayResolveInfo; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -64,68 +64,61 @@ import java.util.stream.Collectors; public class ChooserTargetActionsDialogFragment extends DialogFragment implements DialogInterface.OnClickListener { - protected ArrayList mTargetInfos = new ArrayList<>(); - protected UserHandle mUserHandle; - protected String mShortcutId; - protected String mShortcutTitle; - protected boolean mIsShortcutPinned; - protected IntentFilter mIntentFilter; + protected final static String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment"; - public static final String USER_HANDLE_KEY = "user_handle"; - public static final String TARGET_INFOS_KEY = "target_infos"; - public static final String SHORTCUT_ID_KEY = "shortcut_id"; - public static final String SHORTCUT_TITLE_KEY = "shortcut_title"; - public static final String IS_SHORTCUT_PINNED_KEY = "is_shortcut_pinned"; - public static final String INTENT_FILTER_KEY = "intent_filter"; + private final List mTargetInfos; + private final UserHandle mUserHandle; + private final boolean mIsShortcutPinned; - public ChooserTargetActionsDialogFragment() {} + @Nullable + private final String mShortcutId; + + @Nullable + private final String mShortcutTitle; + + @Nullable + private final IntentFilter mIntentFilter; + + public static void show( + FragmentManager fragmentManager, + List targetInfos, + UserHandle userHandle, + @Nullable String shortcutId, + @Nullable String shortcutTitle, + boolean isShortcutPinned, + @Nullable IntentFilter intentFilter) { + ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment( + targetInfos, + userHandle, + shortcutId, + shortcutTitle, + isShortcutPinned, + intentFilter); + fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG); + } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (savedInstanceState != null) { - setStateFromBundle(savedInstanceState); - } else { - setStateFromBundle(getArguments()); + // Bail. It's probably not possible to trigger reloading our fragments from a saved + // instance since Sharesheet isn't kept in history and the entire session will probably + // be lost under any conditions that would've triggered our retention. Nevertheless, if + // we ever *did* try to load from a saved state, we wouldn't be able to populate valid + // data (since we wouldn't be able to get back our original TargetInfos if we had to + // restore them from a Bundle). + dismissAllowingStateLoss(); } } - void setStateFromBundle(Bundle b) { - mTargetInfos = (ArrayList) b.get(TARGET_INFOS_KEY); - mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); - mShortcutId = b.getString(SHORTCUT_ID_KEY); - mShortcutTitle = b.getString(SHORTCUT_TITLE_KEY); - mIsShortcutPinned = b.getBoolean(IS_SHORTCUT_PINNED_KEY); - mIntentFilter = (IntentFilter) b.get(INTENT_FILTER_KEY); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, - mUserHandle); - outState.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, - mTargetInfos); - outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, mShortcutId); - outState.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, - mIsShortcutPinned); - outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, mShortcutTitle); - outState.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, mIntentFilter); - } - /** - * Recreate the layout from scratch to match new Sharesheet redlines + * Build the menu UI according to our design spec. */ @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - if (savedInstanceState != null) { - setStateFromBundle(savedInstanceState); - } else { - setStateFromBundle(getArguments()); - } // Make the background transparent to show dialog rounding Optional.of(getDialog()).map(Dialog::getWindow) .ifPresent(window -> { @@ -294,4 +287,24 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment private boolean isShortcutTarget() { return mShortcutId != null; } + + protected ChooserTargetActionsDialogFragment( + List targetInfos, UserHandle userHandle) { + this(targetInfos, userHandle, null, null, false, null); + } + + private ChooserTargetActionsDialogFragment( + List targetInfos, + UserHandle userHandle, + @Nullable String shortcutId, + @Nullable String shortcutTitle, + boolean isShortcutPinned, + @Nullable IntentFilter intentFilter) { + mTargetInfos = targetInfos; + mUserHandle = userHandle; + mShortcutId = shortcutId; + mShortcutTitle = shortcutTitle; + mIsShortcutPinned = isShortcutPinned; + mIntentFilter = intentFilter; + } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index cd6828c7..daa69152 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -27,8 +27,6 @@ import android.content.pm.ApplicationInfo; import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.os.Parcel; -import android.os.Parcelable; import android.os.UserHandle; import com.android.intentresolver.ResolverActivity; @@ -42,7 +40,7 @@ import java.util.List; * A TargetInfo plus additional information needed to render it (such as icon and label) and * resolve it to an activity. */ -public class DisplayResolveInfo implements TargetInfo, Parcelable { +public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; private CharSequence mDisplayLabel; private Drawable mDisplayIcon; @@ -237,41 +235,4 @@ public class DisplayResolveInfo implements TargetInfo, Parcelable { public void setPinned(boolean pinned) { mPinned = pinned; } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeCharSequence(mDisplayLabel); - dest.writeCharSequence(mExtendedInfo); - dest.writeParcelable(mResolvedIntent, 0); - dest.writeTypedList(mSourceIntents); - dest.writeBoolean(mIsSuspended); - dest.writeBoolean(mPinned); - dest.writeParcelable(mResolveInfo, 0); - } - - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - public DisplayResolveInfo createFromParcel(Parcel in) { - return new DisplayResolveInfo(in); - } - - public DisplayResolveInfo[] newArray(int size) { - return new DisplayResolveInfo[size]; - } - }; - - private DisplayResolveInfo(Parcel in) { - mDisplayLabel = in.readCharSequence(); - mExtendedInfo = in.readCharSequence(); - mResolvedIntent = in.readParcelable(null /* ClassLoader */, android.content.Intent.class); - in.readTypedList(mSourceIntents, Intent.CREATOR); - mIsSuspended = in.readBoolean(); - mPinned = in.readBoolean(); - mResolveInfo = in.readParcelable(null /* ClassLoader */, android.content.pm.ResolveInfo.class); - } } -- cgit v1.2.3-59-g8ed1b From 578b259bd38d2a75fc7b1ecca1bfeb2009d99e07 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 28 Oct 2022 14:16:24 -0700 Subject: Remove ChooserListAdapter notifyDataSetChange throttling ChooserListAdapter notifyDataSetChange throttling period has been, practically, reduced to 0 and this change removes the logic completely. The remaining ChooserHandler's message turned into posting on the UI thread and thus ChooserHandler is also removed. Safety Check All ChooserListAdapter#notifyDataSetChanged invocations I've found (posted below) are originated by some posting on the main thread i.e. not initiated from a view binding in the adapter. Invocations - ChooserActivity.onHandlePackageChanged (and - ResolverActivity.onHandlePackageChanged) - ChooserListAdapter.onListRebuilt - ChooserListAdapter.updateAlphabeticalList - ChooserListAdapter.addServiceResults - ChooserListAdapter.completeServiceTargetLoading - ChooserListAdapter$ from #createSortingTask - ChooserListAdapter.LoadDirectShareIconTask - ResolverListAdapter.LoadIconTask - ResolverListAdapter.LoadLabelTask Fix: 257285237 Test: manual test Test: atest IntentResolverUnitTests Change-Id: I70eeababa2fa684f0a160ff6ed9ab0900e3c9648 --- .../android/intentresolver/ChooserActivity.java | 150 ++++++--------------- .../android/intentresolver/ChooserListAdapter.java | 20 --- .../UnbundledChooserActivityTest.java | 51 ++----- 3 files changed, 52 insertions(+), 169 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 6735ab4e..53d3829e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -88,7 +88,6 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.util.HashedStringCache; import android.util.Log; -import android.util.Pair; import android.util.PluralsMessageFormatter; import android.util.Size; import android.util.Slog; @@ -111,6 +110,7 @@ import android.widget.ImageView; import android.widget.Space; import android.widget.TextView; +import androidx.annotation.MainThread; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -238,22 +238,15 @@ public class ChooserActivity extends ResolverActivity implements private static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; - private int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, + private final int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, DEFAULT_SALT_EXPIRATION_DAYS); - private static final int DEFAULT_LIST_VIEW_UPDATE_DELAY_MS = 0; - private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - @VisibleForTesting - int mListViewUpdateDelayMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, - SystemUiDeviceConfigFlags.SHARESHEET_LIST_VIEW_UPDATE_DELAY, - DEFAULT_LIST_VIEW_UPDATE_DELAY_MS); - private Bundle mReplacementExtras; private IntentSender mChosenComponentSender; private IntentSender mRefinementIntentSender; @@ -454,74 +447,6 @@ public class ChooserActivity extends ResolverActivity implements } } - private final ChooserHandler mChooserHandler = new ChooserHandler(); - - private class ChooserHandler extends Handler { - private static final int LIST_VIEW_UPDATE_MESSAGE = 6; - private static final int SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS = 7; - - private void removeAllMessages() { - removeMessages(LIST_VIEW_UPDATE_MESSAGE); - removeMessages(SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS); - } - - @Override - public void handleMessage(Message msg) { - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null || isDestroyed()) { - return; - } - - switch (msg.what) { - case LIST_VIEW_UPDATE_MESSAGE: - if (DEBUG) { - Log.d(TAG, "LIST_VIEW_UPDATE_MESSAGE; "); - } - - mChooserMultiProfilePagerAdapter - .getListAdapterForUserHandle((UserHandle) msg.obj) - .refreshListView(); - break; - - case SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS: - if (DEBUG) Log.d(TAG, "SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS"); - final Pair args = - (Pair) msg.obj; - final UserHandle userHandle = args.first; - final ServiceResultInfo[] resultInfos = args.second; - - for (ServiceResultInfo resultInfo : resultInfos) { - if (resultInfo.resultTargets != null) { - ChooserListAdapter adapterForUserHandle = - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - resultInfo.userHandle); - if (adapterForUserHandle != null) { - adapterForUserHandle.addServiceResults( - resultInfo.originalTarget, - resultInfo.resultTargets, - msg.arg1, - emptyIfNull(mDirectShareShortcutInfoCache), - emptyIfNull(mDirectShareAppTargetCache)); - } - } - } - - logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, - userHandle); - - sendVoiceChoicesIfNeeded(); - getChooserActivityLogger().logSharesheetDirectLoadComplete(); - - mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .completeServiceTargetLoading(); - break; - - default: - super.handleMessage(msg); - } - } - }; - @Override protected void onCreate(Bundle savedInstanceState) { final long intentReceivedTime = System.currentTimeMillis(); @@ -791,8 +716,7 @@ public class ChooserActivity extends ResolverActivity implements new ComponentName( appTarget.getPackageName(), appTarget.getClassName()))); } - sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList, - chooserListAdapter.getUserHandle()); + sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList); }; } @@ -1622,7 +1546,6 @@ public class ChooserActivity extends ResolverActivity implements mRefinementResultReceiver.destroy(); mRefinementResultReceiver = null; } - mChooserHandler.removeAllMessages(); if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); @@ -2003,7 +1926,7 @@ public class ChooserActivity extends ResolverActivity implements ShortcutManager sm = (ShortcutManager) selectedProfileContext .getSystemService(Context.SHORTCUT_SERVICE); List resultList = sm.getShareTargets(filter); - sendShareShortcutInfoList(resultList, adapter, null, userHandle); + sendShareShortcutInfoList(resultList, adapter, null); }); } @@ -2033,12 +1956,13 @@ public class ChooserActivity extends ResolverActivity implements private void sendShareShortcutInfoList( List resultList, ChooserListAdapter chooserListAdapter, - @Nullable List appTargets, UserHandle userHandle) { + @Nullable List appTargets) { if (appTargets != null && appTargets.size() != resultList.size()) { throw new RuntimeException("resultList and appTargets must have the same size." + " resultList.size()=" + resultList.size() + " appTargets.size()=" + appTargets.size()); } + UserHandle userHandle = chooserListAdapter.getUserHandle(); Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); for (int i = resultList.size() - 1; i >= 0; i--) { final String packageName = resultList.get(i).getTargetComponent().getPackageName(); @@ -2075,12 +1999,15 @@ public class ChooserActivity extends ResolverActivity implements mDirectShareShortcutInfoCache); ServiceResultInfo resultRecord = new ServiceResultInfo( - displayResolveInfo, chooserTargets, userHandle); + displayResolveInfo, chooserTargets); resultRecords.add(resultRecord); } - sendShortcutManagerShareTargetResults( - userHandle, shortcutType, resultRecords.toArray(new ServiceResultInfo[0])); + runOnUiThread(() -> { + if (!isDestroyed()) { + onShortcutsLoaded(chooserListAdapter, shortcutType, resultRecords); + } + }); } private List filterShortcutsByTargetComponentName( @@ -2094,15 +2021,6 @@ public class ChooserActivity extends ResolverActivity implements return matchingShortcuts; } - private void sendShortcutManagerShareTargetResults( - UserHandle userHandle, int shortcutType, ServiceResultInfo[] results) { - final Message msg = Message.obtain(); - msg.what = ChooserHandler.SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS; - msg.obj = Pair.create(userHandle, results); - msg.arg1 = shortcutType; - mChooserHandler.sendMessage(msg); - } - private boolean isPackageEnabled(Context context, String packageName) { if (TextUtils.isEmpty(packageName)) { return false; @@ -2579,14 +2497,6 @@ public class ChooserActivity extends ResolverActivity implements return mMaxTargetsPerRow; } - @Override // ChooserListCommunicator - public void sendListViewUpdateMessage(UserHandle userHandle) { - Message msg = Message.obtain(); - msg.what = ChooserHandler.LIST_VIEW_UPDATE_MESSAGE; - msg.obj = userHandle; - mChooserHandler.sendMessageDelayed(msg, mListViewUpdateDelayMs); - } - @Override public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); @@ -2633,6 +2543,33 @@ public class ChooserActivity extends ResolverActivity implements queryDirectShareTargets(chooserListAdapter, false); } + @MainThread + private void onShortcutsLoaded( + ChooserListAdapter adapter, int targetType, List resultInfos) { + UserHandle userHandle = adapter.getUserHandle(); + if (DEBUG) { + Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); + } + for (ServiceResultInfo resultInfo : resultInfos) { + if (resultInfo.resultTargets != null) { + adapter.addServiceResults( + resultInfo.originalTarget, + resultInfo.resultTargets, + targetType, + emptyIfNull(mDirectShareShortcutInfoCache), + emptyIfNull(mDirectShareAppTargetCache)); + } + } + adapter.completeServiceTargetLoading(); + + logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + userHandle); + + sendVoiceChoicesIfNeeded(); + getChooserActivityLogger().logSharesheetDirectLoadComplete(); + } + @VisibleForTesting protected boolean isUserRunning(UserHandle userHandle) { UserManager userManager = getSystemService(UserManager.class); @@ -2965,12 +2902,12 @@ public class ChooserActivity extends ResolverActivity implements */ @VisibleForTesting public final class ChooserGridAdapter extends RecyclerView.Adapter { - private ChooserListAdapter mChooserListAdapter; + private final ChooserListAdapter mChooserListAdapter; private final LayoutInflater mLayoutInflater; private DirectShareViewHolder mDirectShareViewHolder; private int mChooserTargetWidth = 0; - private boolean mShowAzLabelIfPoss; + private final boolean mShowAzLabelIfPoss; private boolean mLayoutRequested = false; private int mFooterHeight = 0; @@ -3703,13 +3640,10 @@ public class ChooserActivity extends ResolverActivity implements static class ServiceResultInfo { public final DisplayResolveInfo originalTarget; public final List resultTargets; - public final UserHandle userHandle; - public ServiceResultInfo(DisplayResolveInfo ot, List rt, - UserHandle userHandle) { + ServiceResultInfo(DisplayResolveInfo ot, List rt) { originalTarget = ot; resultTargets = rt; - this.userHandle = userHandle; } } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 2443659f..92cd0043 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -33,7 +33,6 @@ import android.content.pm.ShortcutInfo; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.Trace; -import android.os.UserHandle; import android.os.UserManager; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; @@ -90,8 +89,6 @@ public class ChooserListAdapter extends ResolverListAdapter { private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); - private boolean mListViewDataChanged = false; - // Sorted list of DisplayResolveInfos for the alphabetical app section. private List mSortedList = new ArrayList<>(); @@ -225,21 +222,6 @@ public class ChooserListAdapter extends ResolverListAdapter { } - @Override - public void notifyDataSetChanged() { - if (!mListViewDataChanged) { - mChooserListCommunicator.sendListViewUpdateMessage(getUserHandle()); - mListViewDataChanged = true; - } - } - - void refreshListView() { - if (mListViewDataChanged) { - super.notifyDataSetChanged(); - } - mListViewDataChanged = false; - } - private void createPlaceHolders() { mServiceTargets.clear(); for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { @@ -652,8 +634,6 @@ public class ChooserListAdapter extends ResolverListAdapter { int getMaxRankedTargets(); - void sendListViewUpdateMessage(UserHandle userHandle); - boolean isSendAction(Intent targetIntent); } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index ee801101..dfdbeda7 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -636,7 +636,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void hasOtherProfileOneOption() throws Exception { + public void hasOtherProfileOneOption() { List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(4); @@ -662,7 +662,6 @@ public class UnbundledChooserActivityTest { List stableCopy = createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); waitForIdle(); - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) .perform(click()); @@ -1278,7 +1277,7 @@ public class UnbundledChooserActivityTest { // This test is too long and too slow and should not be taken as an example for future tests. @Test @Ignore - public void testDirectTargetSelectionLogging() throws InterruptedException { + public void testDirectTargetSelectionLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1321,11 +1320,6 @@ public class UnbundledChooserActivityTest { /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", @@ -1356,7 +1350,7 @@ public class UnbundledChooserActivityTest { // This test is too long and too slow and should not be taken as an example for future tests. @Test @Ignore - public void testDirectTargetLoggingWithRankedAppTarget() throws InterruptedException { + public void testDirectTargetLoggingWithRankedAppTarget() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1399,10 +1393,6 @@ public class UnbundledChooserActivityTest { directShareToShortcutInfos, /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); @@ -1429,7 +1419,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void testShortcutTargetWithApplyAppLimits() throws InterruptedException { + public void testShortcutTargetWithApplyAppLimits() { // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); @@ -1482,10 +1472,6 @@ public class UnbundledChooserActivityTest { directShareToShortcutInfos, /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", wrapper.getAdapter().getCount(), is(3)); @@ -1498,7 +1484,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void testShortcutTargetWithoutApplyAppLimits() throws InterruptedException { + public void testShortcutTargetWithoutApplyAppLimits() { setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, Boolean.toString(false)); @@ -1554,10 +1540,6 @@ public class UnbundledChooserActivityTest { directShareToShortcutInfos, /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat("Chooser should have 4 targets (2 apps, 2 direct)", wrapper.getAdapter().getCount(), is(4)); @@ -1604,8 +1586,7 @@ public class UnbundledChooserActivityTest { } private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected - ) throws InterruptedException { + int orientation, int appTargetsExpected) { Configuration configuration = new Configuration(InstrumentationRegistry.getInstrumentation().getContext() .getResources().getConfiguration()); @@ -1643,9 +1624,8 @@ public class UnbundledChooserActivityTest { ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0); // Start activity - final IChooserWrapper activity = (IChooserWrapper) + final IChooserWrapper wrapper = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; // Insert the direct share target Map directShareToShortcutInfos = new HashMap<>(); directShareToShortcutInfos.put(serviceTargets.get(0), null); @@ -1662,10 +1642,6 @@ public class UnbundledChooserActivityTest { directShareToShortcutInfos, /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat( String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", @@ -1764,7 +1740,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); @@ -1780,13 +1756,10 @@ public class UnbundledChooserActivityTest { return true; }; - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); waitForIdle(); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - // wait for the share sheet to expand - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); onView(first(allOf( withText(workResolvedComponentInfos.get(0) @@ -1957,7 +1930,7 @@ public class UnbundledChooserActivityTest { } @Test @Ignore - public void testDirectTargetLogging() throws InterruptedException { + public void testDirectTargetLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1997,10 +1970,6 @@ public class UnbundledChooserActivityTest { directShareToShortcutInfos, /* directShareToAppTargets */ null) ); - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - // TODO: restructure the tests b/129870719 - Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); -- cgit v1.2.3-59-g8ed1b From 53705d85ff4092c94592b281d3a05403bae01665 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 3 Nov 2022 15:14:38 -0400 Subject: Don't expose `ChooserTarget` from `TargetInfo` API. `ChooserTarget` is a deprecated API left over from the (now long-gone) `ChooserTargetService` system, but internally, `SelectableTargetInfo` still relies on this type for some basic record-keeping. The `ChooserTarget`s today come from two sources; they're either passed in from the caller via `EXTRA_CHOOSER_TARGETS`, or synthesized internally to shoehorn non-`ChooserTarget` data into a `SelectableTargetInfo`. Prior to this CL, clients throughout the application might reach through to the composed-in `ChooserTarget` for some fields stored in that record type. After this CL, any fields accessed in that way are instead lifted to the `TargetInfo` API so that clients can access them directly, without going through a `ChooserTarget`. Thus "consuming" clients don't need to be aware of the `ChooserTarget`, and from their perspective it's an internal implementation detail of `SelectableTargetInfo`. In a first subsequent CL, these fields can be precomputed from the `ChooserTarget` during the construction of the `SelectableTargetInfo` so that we don't retain any instances once we've extracted the relevant data. In a second subsequent CL, we can expose those fields as initialization parameters; then instead of synthesizing intermediate `ChooserTarget`s, we can build our synthetic targets as `SelectableTargetInfo`s directly. The usage in `EXTRA_CHOOSER_TARGETS` is regrettably part of our public API that was overlooked in the `ChooserTargetService` deprecation and won't be going away any time soon, but if we can adapt them to the `SelectableTargetInfo` representation immediately when we read them out of the request, we should be able to cut out any other remaining references to `ChooserTarget`. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I931234b0912952a4ccf5f94a88694ac82b527ae4 --- .../android/intentresolver/ChooserActivity.java | 21 +---- .../intentresolver/chooser/ChooserTargetInfo.java | 23 ------ .../chooser/NotSelectableTargetInfo.java | 5 -- .../chooser/SelectableTargetInfo.java | 33 ++++++-- .../android/intentresolver/chooser/TargetInfo.java | 95 ++++++++++++++++------ .../intentresolver/ShortcutSelectionLogicTest.kt | 11 ++- .../intentresolver/chooser/TargetInfoTest.kt | 6 +- 7 files changed, 117 insertions(+), 77 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 96f2f9c6..76c6fb29 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1723,12 +1723,7 @@ public class ChooserActivity extends ResolverActivity implements targetInfo.isSelectableTargetInfo() ? getTargetIntentFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; - String shortcutIdKey = targetInfo.isSelectableTargetInfo() - ? targetInfo - .getChooserTarget() - .getIntentExtras() - .getString(Intent.EXTRA_SHORTCUT_ID) - : null; + String shortcutIdKey = targetInfo.getDirectShareShortcutId(); ChooserTargetActionsDialogFragment.show( getSupportFragmentManager(), @@ -1816,15 +1811,7 @@ public class ChooserActivity extends ResolverActivity implements switch (currentListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_SERVICE: cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; - // Log the package name + target name to answer the question if most users - // share to mostly the same person or to a bunch of different people. - ChooserTarget target = targetInfo.getChooserTarget(); - directTargetHashed = HashedStringCache.getInstance().hashString( - this, - TAG, - target.getComponentName().getPackageName() - + target.getTitle().toString(), - mMaxHashSaltDays); + directTargetHashed = targetInfo.getHashedTargetIdForMetrics(this); directTargetAlsoRanked = getRankedPosition(targetInfo); if (mCallerChooserTargets != null) { @@ -1894,7 +1881,7 @@ public class ChooserActivity extends ResolverActivity implements private int getRankedPosition(TargetInfo targetInfo) { String targetPackageName = - targetInfo.getChooserTarget().getComponentName().getPackageName(); + targetInfo.getChooserTargetComponentName().getPackageName(); ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); int maxRankedResults = Math.min(currentListAdapter.mDisplayList.size(), @@ -2165,7 +2152,7 @@ public class ChooserActivity extends ResolverActivity implements ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); if (shortcutInfo != null) { ComponentName componentName = - chooserTargetInfo.getChooserTarget().getComponentName(); + chooserTargetInfo.getChooserTargetComponentName(); targetIds.add(new AppTargetId( String.format( "%s/%s/%s", diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 2de901cd..8b9bfb32 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,9 +16,6 @@ package com.android.intentresolver.chooser; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; - import java.util.ArrayList; import java.util.Arrays; @@ -42,24 +39,4 @@ public abstract class ChooserTargetInfo implements TargetInfo { } return new ArrayList<>(Arrays.asList(getDisplayResolveInfo())); } - - @Override - public boolean isSimilar(TargetInfo other) { - if (other == null) return false; - - ChooserTarget ct1 = getChooserTarget(); - ChooserTarget ct2 = other.getChooserTarget(); - - // If either is null, there is not enough info to make an informed decision - // about equality, so just exit - if (ct1 == null || ct2 == null) return false; - - if (ct1.getComponentName().equals(ct2.getComponentName()) - && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel()) - && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo())) { - return true; - } - - return false; - } } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 66e2022c..8ec52c8a 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -25,7 +25,6 @@ import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; -import android.service.chooser.ChooserTarget; import com.android.intentresolver.R; import com.android.intentresolver.ResolverActivity; @@ -131,10 +130,6 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { return -0.1f; } - public ChooserTarget getChooserTarget() { - return null; - } - public boolean isSuspended() { return false; } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 3a3c3e64..7e6e49fb 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -34,8 +34,10 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.UserHandle; +import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.text.SpannableStringBuilder; +import android.util.HashedStringCache; import android.util.Log; import com.android.intentresolver.ChooserActivity; @@ -43,6 +45,7 @@ import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; import com.android.intentresolver.SimpleIconFactory; import com.android.internal.annotations.GuardedBy; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; import java.util.List; @@ -54,6 +57,14 @@ import java.util.List; public final class SelectableTargetInfo extends ChooserTargetInfo { private static final String TAG = "SelectableTargetInfo"; + private static final String HASHED_STRING_CACHE_TAG = "ChooserActivity"; // For legacy reasons. + private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; + + private final int mMaxHashSaltDays = DeviceConfig.getInt( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, + DEFAULT_SALT_EXPIRATION_DAYS); + private final Context mContext; @Nullable private final DisplayResolveInfo mSourceInfo; @@ -280,6 +291,11 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return null; } + @Override + public ComponentName getChooserTargetComponentName() { + return mChooserTarget.getComponentName(); + } + private Intent getBaseIntentToSend() { Intent result = getResolvedIntent(); if (result == null) { @@ -358,11 +374,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mDisplayIcon != null; } - @Override - public ChooserTarget getChooserTarget() { - return mChooserTarget; - } - @Override @Nullable public ShortcutInfo getDirectShareShortcutInfo() { @@ -395,6 +406,18 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mIsPinned; } + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + final String plaintext = + mChooserTarget.getComponentName().getPackageName() + + mChooserTarget.getTitle().toString(); + return HashedStringCache.getInstance().hashString( + context, + HASHED_STRING_CACHE_TAG, + plaintext, + mMaxHashSaltDays); + } + @Nullable private static ApplicationInfo getApplicationInfoFromSource( @Nullable DisplayResolveInfo sourceInfo) { diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 29c4cfc3..46cd53c6 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -29,6 +29,8 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.HashedStringCache; import com.android.intentresolver.ResolverActivity; @@ -52,12 +54,33 @@ public interface TargetInfo { /** * Get the resolved component name that represents this target. Note that this may not * be the component that will be directly launched by calling one of the start - * methods provided; this is the component that will be credited with the launch. + * methods provided; this is the component that will be credited with the launch. This may be + * null if the target was specified by a caller-provided {@link ChooserTarget} that we failed to + * resolve to a component on the system. * * @return the resolved ComponentName for this target */ + @Nullable ComponentName getResolvedComponentName(); + /** + * If this target was historically built from a (now-deprecated) {@link ChooserTarget} record, + * get the {@link ComponentName} that would've been provided by that record. + * + * TODO: for (historical) {@link ChooserTargetInfo} targets, this differs from the result of + * {@link #getResolvedComponentName()} only for caller-provided targets that we fail to resolve; + * then this returns the name of the component that was requested, and the other returns null. + * At the time of writing, this method is only called in contexts where the client knows that + * the target was a historical {@link ChooserTargetInfo}. Thus this method could be removed and + * all clients consolidated on the other, if we have some alternate mechanism of tracking this + * discrepancy; or if we know that the distinction won't apply in the conditions when we call + * this method; or if we determine that tracking the distinction isn't a requirement for us. + */ + @Nullable + default ComponentName getChooserTargetComponentName() { + return null; + } + /** * Start the activity referenced by this target. * @@ -172,7 +195,25 @@ public interface TargetInfo { * presumably resolved by converting {@code TargetInfo} from an interface to an abstract class. */ default boolean isSimilar(TargetInfo other) { - return Objects.equals(this, other); + if (other == null) { + return false; + } + + // TODO: audit usage and try to reconcile a behavior that doesn't depend on the legacy + // subclass type. Note that the `isSimilar()` method was pulled up from the legacy + // `ChooserTargetInfo`, so no legacy behavior currently depends on calling `isSimilar()` on + // an instance where `isChooserTargetInfo()` would return false (although technically it may + // have been possible for the `other` target to be of a different type). Thus we have + // flexibility in defining the similarity conditions between pairs of non "chooser" targets. + if (isChooserTargetInfo()) { + return other.isChooserTargetInfo() + && Objects.equals( + getChooserTargetComponentName(), other.getChooserTargetComponentName()) + && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel()) + && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo()); + } else { + return !other.isChooserTargetInfo() && Objects.equals(this, other); + } } /** @@ -186,29 +227,24 @@ public interface TargetInfo { } /** - * @return the {@link ChooserTarget} record that contains additional data about this target, if - * any. This is only non-null for selectable targets (and probably only Direct Share targets?). - * - * @deprecated {@link ChooserTarget} (and any other related {@code ChooserTargetService} APIs) - * got deprecated as part of sunsetting that old system design, but for historical reasons - * Chooser continues to shoehorn data from other sources into this representation to maintain - * compatibility with legacy internal APIs. New clients should avoid taking any further - * dependencies on the {@link ChooserTarget} type; any data they want to query from those - * records should instead be pulled up to new query methods directly on this class (or on the - * root {@link TargetInfo}). - */ - @Deprecated + * @return the {@link ShortcutManager} data for any shortcut associated with this target. + */ @Nullable - default ChooserTarget getChooserTarget() { + default ShortcutInfo getDirectShareShortcutInfo() { return null; } /** - * @return the {@link ShortcutManager} data for any shortcut associated with this target. + * @return the ID of the shortcut represented by this target, or null if the target didn't come + * from a {@link ShortcutManager} shortcut. */ @Nullable - default ShortcutInfo getDirectShareShortcutInfo() { - return null; + default String getDirectShareShortcutId() { + ShortcutInfo shortcut = getDirectShareShortcutInfo(); + if (shortcut == null) { + return null; + } + return shortcut.getId(); } /** @@ -255,11 +291,11 @@ public interface TargetInfo { * @return true if this target represents a legacy {@code ChooserTargetInfo}. These objects were * historically documented as representing "[a] TargetInfo for Direct Share." However, not all * of these targets are actually *valid* for direct share; e.g. some represent "empty" items - * (although perhaps only for display in the Direct Share UI?). {@link #getChooserTarget()} will - * return null for any of these "invalid" items. In even earlier versions, these targets may - * also have been results from (now-deprecated/unsupported) {@code ChooserTargetService} peers; - * even though we no longer use these services, we're still shoehorning other target data into - * the deprecated {@link ChooserTarget} structure for compatibility with some internal APIs. + * (although perhaps only for display in the Direct Share UI?). In even earlier versions, these + * targets may also have been results from peers in the (now-deprecated/unsupported) + * {@code ChooserTargetService} ecosystem; even though we no longer use these services, we're + * still shoehorning other target data into the deprecated {@link ChooserTarget} structure for + * compatibility with some internal APIs. * TODO: refactor to clarify the semantics of any target for which this method returns true * (e.g., are they characterized by their application in the Direct Share UI?), and to remove * the scaffolding that adapts to and from the {@link ChooserTarget} structure. Eventually, we @@ -351,6 +387,19 @@ public interface TargetInfo { return isChooserTargetInfo(); } + /** + * @param context caller's context, to provide the {@link SharedPreferences} for use by the + * {@link HashedStringCache}. + * @return a hashed ID that should be logged along with our target-selection metrics, or null. + * The contents of the plaintext are defined for historical reasons, "the package name + target + * name to answer the question if most users share to mostly the same person + * or to a bunch of different people." Clients should consider this as opaque data for logging + * only; they should not rely on any particular semantics about the value. + */ + default HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return null; + } + /** * Fix the URIs in {@code intent} if cross-profile sharing is required. This should be called * before launching the intent as another user. diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index f45d592f..8581ed0c 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -261,6 +261,8 @@ class ShortcutSelectionLogicTest { ) } + // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases + // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`. private fun assertShortcutsInOrder( expected: List, actual: List, msg: String? = "" ) { @@ -268,8 +270,13 @@ class ShortcutSelectionLogicTest { for (i in expected.indices) { assertEquals( "Unexpected item at position $i", - expected[i], - actual[i].chooserTarget + expected[i].componentName, + actual[i].chooserTargetComponentName + ) + assertEquals( + "Unexpected item at position $i", + expected[i].title, + actual[i].displayLabel ) } } diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index b6d0962b..ae9c0f8d 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -76,7 +76,9 @@ class TargetInfoTest { assertThat(targetInfo.isSelectableTargetInfo()).isTrue() assertThat(targetInfo.isChooserTargetInfo()).isTrue() // From legacy inheritance model. assertThat(targetInfo.getDisplayResolveInfo()).isSameInstanceAs(displayInfo) - assertThat(targetInfo.getChooserTarget()).isSameInstanceAs(chooserTarget) + assertThat(targetInfo.getChooserTargetComponentName()) + .isEqualTo(chooserTarget.getComponentName()) + assertThat(targetInfo.getDirectShareShortcutId()).isEqualTo(shortcutInfo.getId()) assertThat(targetInfo.getDirectShareShortcutInfo()).isSameInstanceAs(shortcutInfo) assertThat(targetInfo.getDirectShareAppTarget()).isSameInstanceAs(appTarget) // TODO: make more meaningful assertions about the behavior of a selectable target. @@ -147,4 +149,4 @@ class TargetInfoTest { // TODO: consider exercising activity-start behavior. // TODO: consider exercising DisplayResolveInfo base class behavior. } -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From 41e730a498c4278ca4bb1c2d20a5805bc62e3481 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 4 Nov 2022 14:18:24 -0400 Subject: Simplify ContentPreviewCoordinator (pure refactor) This component effectively serves as the bridge between our (mostly standalone) content preview logic and the ChooserActivity application where that preview is displayed. A subsequent refactoring CL will migrate the content preview logic out of ChooserActivity, so this is a preliminary step to decouple dependencies on unrelated ChooserActivity concerns (after this CL, we could even choose to migrate the definition of the ContentPreviewCoordinator class out of ChooserActivity alongside the rest of the preview logic). Changes are small to aid reviewers in understanding that this CL should cause no behavioral changes. Any more-significant cleanup is out-of-scope for now and should wait until we resolve a clearer picture of where various responsibilities will land in the new application architecture. Main changes: 1. Make the inner `ContentPreviewCoordinator` class `static`, then inject a reference to the `ChooserActivity` in the constructor. That back-reference technically means that this is *barely* less coupled than before (i.e., it's just setting up a "code move" to organize our source code, without fundamentally improving the design). The reference isn't used for much -- access to package resources, lifecycle state, and one preview-related method that I expect to move in the next CL -- so it can probably be cleaned up for better decoupling once the dust settles. Either way, the `static` declaration explicates any dependencies to the outer `ChooserActivity` so we can more easily reason about the inner class component. 2. Inline `hideParentOnFail = false`. This was hard-coded at the three call sites where we construct the coordinator objects, so I removed the argument and deleted the extra complexity in handling it (including some knock-on concerns in the outer class). 3. Inject success/failure callbacks to delegate the parts of the logic that relate to `ChooserActivity` concerns which are otherwise tangential to content previews. These are just specified as `Consumer` and `Runnable` for now (respectively) for simplicity but could be replaced by a more purpose-built interface (IMO probably worthwhile only if the responsibilities expand?) 4. Add visibility specifiers (and re-order methods) to show which parts of the `ContentPreviewCoordinator` API are actually used by "clients." They're now specified as they'd need to be if the class ends up being migrated out of `ChooserActivity`. (EDIT: presubmit hooks kicked back one of the changes since it technically can't matter in the current location, but I just converted it to a comment for now, because IMO it's still useful info, and there's no guarantee the class is staying "in the current location.") 5. Document TODOs and make other minor style changes I felt would improve readability. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I098ccb8e419dee1b301a85da07bda573cc94f5f8 --- .../android/intentresolver/ChooserActivity.java | 248 ++++++++++----------- 1 file changed, 117 insertions(+), 131 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3ccefe1b..69ac9b65 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -146,6 +146,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -300,149 +301,139 @@ public class ChooserActivity extends ResolverActivity implements new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); - private class ContentPreviewCoordinator { + private static class ContentPreviewCoordinator { + + /* public */ ContentPreviewCoordinator( + ChooserActivity chooserActivity, + View parentView, + Runnable onFailCallback, + Consumer onSingleImageSuccessCallback) { + this.mChooserActivity = chooserActivity; + this.mParentView = parentView; + this.mOnFailCallback = onFailCallback; + this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; + + this.mImageLoadTimeoutMillis = + chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); + } + + public void cancelLoads() { + mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); + mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); + } + private static final int IMAGE_FADE_IN_MILLIS = 150; private static final int IMAGE_LOAD_TIMEOUT = 1; private static final int IMAGE_LOAD_INTO_VIEW = 2; - private final int mImageLoadTimeoutMillis = - getResources().getInteger(R.integer.config_shortAnimTime); - + private final ChooserActivity mChooserActivity; private final View mParentView; - private boolean mHideParentOnFail; - private boolean mAtLeastOneLoaded = false; + private final Runnable mOnFailCallback; + private final Consumer mOnSingleImageSuccessCallback; + private final int mImageLoadTimeoutMillis; - class LoadUriTask { - public final Uri mUri; - public final int mImageResourceId; - public final int mExtraCount; - public final Bitmap mBmp; - - LoadUriTask(int imageResourceId, Uri uri, int extraCount, Bitmap bmp) { - this.mImageResourceId = imageResourceId; - this.mUri = uri; - this.mExtraCount = extraCount; - this.mBmp = bmp; - } - } + private boolean mAtLeastOneLoaded = false; - // If at least one image loads within the timeout period, allow other - // loads to continue. Otherwise terminate and optionally hide - // the parent area private final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { - switch (msg.what) { - case IMAGE_LOAD_TIMEOUT: - maybeHideContentPreview(); - break; - - case IMAGE_LOAD_INTO_VIEW: - if (isFinishing()) break; - - LoadUriTask task = (LoadUriTask) msg.obj; - RoundedRectImageView imageView = mParentView.findViewById( - task.mImageResourceId); - if (task.mBmp == null) { - imageView.setVisibility(View.GONE); - maybeHideContentPreview(); - return; - } - - mAtLeastOneLoaded = true; - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(task.mBmp); + if (mChooserActivity.isFinishing()) { + return; + } - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, - 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); + if (msg.what == IMAGE_LOAD_TIMEOUT) { + // If at least one image loads within the timeout period, allow other loads to + // continue. (I.e., only fail if no images have loaded by the timeout event.) + if (!mAtLeastOneLoaded) { + mOnFailCallback.run(); + } + return; + } - if (task.mExtraCount > 0) { - imageView.setExtraImageCount(task.mExtraCount); - } + // TODO: switch off using `Handler`. For now the following conditions implicitly + // rely on the knowledge that we only have two message types (and so after the guard + // clause above, we know this is an `IMAGE_LOAD_INTO_VIEW` message). - setupPreDrawForSharedElementTransition(imageView); + RoundedRectImageView imageView = mParentView.findViewById(msg.arg1); + if (msg.obj != null) { + onImageLoaded((Bitmap) msg.obj, imageView, msg.arg2); + } else { + imageView.setVisibility(View.GONE); + if (!mAtLeastOneLoaded) { + // TODO: this looks like a race condition. We know that this specific image + // failed (i.e. it got a null Bitmap), but we'll only report that to the + // client (thereby failing out our pending loads) if we haven't yet + // succeeded in loading some other non-null Bitmap. But there could be other + // pending loads that would've returned non-null within the timeout window, + // except they end up (effectively) cancelled because this one single-image + // load "finished" (failed) faster. The outcome of that race may be fairly + // predictable (since we *might* imagine that the nulls would usually "load" + // faster?), but it's not guaranteed since the loads are queued in + // `AsyncTask.THREAD_POOL_EXECUTOR` (i.e., in parallel). One option we might + // prefer for more deterministic behavior: don't signal the failure callback + // on a single-image load unless there are no other loads currently pending. + mOnFailCallback.run(); + } } } }; - private void setupPreDrawForSharedElementTransition(View v) { - v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - v.getViewTreeObserver().removeOnPreDrawListener(this); + private void onImageLoaded( + @NonNull Bitmap image, + RoundedRectImageView imageView, + int extraImageCount) { + mAtLeastOneLoaded = true; - if (!mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the - // transition animation. - getWindow().setWindowAnimations(0); - } - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - return true; - } - }); - } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); - ContentPreviewCoordinator(View parentView, boolean hideParentOnFail) { - super(); + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); - this.mParentView = parentView; - this.mHideParentOnFail = hideParentOnFail; + if (extraImageCount > 0) { + imageView.setExtraImageCount(extraImageCount); + } + + mOnSingleImageSuccessCallback.accept(imageView); } - private void loadUriIntoView(final int imageResourceId, final Uri uri, - final int extraImages) { + private void loadUriIntoView( + final int imageViewResourceId, final Uri uri, final int extraImages) { mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis); AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - int size = getResources().getDimensionPixelSize( + int size = mChooserActivity.getResources().getDimensionPixelSize( R.dimen.chooser_preview_image_max_dimen); - final Bitmap bmp = loadThumbnail(uri, new Size(size, size)); - final Message msg = Message.obtain(); - msg.what = IMAGE_LOAD_INTO_VIEW; - msg.obj = new LoadUriTask(imageResourceId, uri, extraImages, bmp); + final Bitmap bmp = mChooserActivity.loadThumbnail(uri, new Size(size, size)); + final Message msg = mHandler.obtainMessage( + IMAGE_LOAD_INTO_VIEW, imageViewResourceId, extraImages, bmp); mHandler.sendMessage(msg); }); } + } - private void cancelLoads() { - mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); - mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); - } + private void setupPreDrawForSharedElementTransition(View v) { + v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + v.getViewTreeObserver().removeOnPreDrawListener(this); - private void maybeHideContentPreview() { - if (!mAtLeastOneLoaded) { - if (mHideParentOnFail) { - Log.i(TAG, "Hiding image preview area. Timed out waiting for preview to load" - + " within " + mImageLoadTimeoutMillis + "ms."); - collapseParentView(); - if (shouldShowTabs()) { - hideStickyContentPreview(); - } else if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { - mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() - .hideContentPreview(); - } - mHideParentOnFail = false; + if (!mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the transition animation. + getWindow().setWindowAnimations(0); } - mRemoveSharedElements = true; mEnterTransitionAnimationDelegate.markImagePreviewReady(); + return true; } - } + }); + } - private void collapseParentView() { - // This will effectively hide the content preview row by forcing the height - // to zero. It is faster than forcing a relayout of the listview - final View v = mParentView; - int widthSpec = MeasureSpec.makeMeasureSpec(v.getWidth(), MeasureSpec.EXACTLY); - int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY); - v.measure(widthSpec, heightSpec); - v.getLayoutParams().height = 0; - v.layout(v.getLeft(), v.getTop(), v.getRight(), v.getTop()); - v.invalidate(); - } + private void hideContentPreview() { + mRemoveSharedElements = true; + mEnterTransitionAnimationDelegate.markImagePreviewReady(); } @Override @@ -1265,7 +1256,11 @@ public class ChooserActivity extends ResolverActivity implements if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); + mPreviewCoord = new ContentPreviewCoordinator( + this, + contentPreviewLayout, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0); } } @@ -1285,7 +1280,11 @@ public class ChooserActivity extends ResolverActivity implements addActionButton(actionRow, createNearbyButton(targetIntent)); addActionButton(actionRow, createEditButton(targetIntent)); - mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); + mPreviewCoord = new ContentPreviewCoordinator( + this, + contentPreviewLayout, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -1456,7 +1455,11 @@ public class ChooserActivity extends ResolverActivity implements fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - mPreviewCoord = new ContentPreviewCoordinator(parent, false); + mPreviewCoord = new ContentPreviewCoordinator( + this, + parent, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0); } else { View thumbnailView = parent.findViewById(com.android.internal.R.id.content_preview_file_thumbnail); @@ -2292,8 +2295,8 @@ public class ChooserActivity extends ResolverActivity implements } final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); - boolean isLayoutUpdated = gridAdapter.consumeLayoutRequest() - || gridAdapter.calculateChooserTargetWidth(availableWidth) + boolean isLayoutUpdated = + gridAdapter.calculateChooserTargetWidth(availableWidth) || recyclerView.getAdapter() == null || availableWidth != mCurrAvailableWidth; @@ -2879,11 +2882,10 @@ public class ChooserActivity extends ResolverActivity implements public final class ChooserGridAdapter extends RecyclerView.Adapter { private final ChooserListAdapter mChooserListAdapter; private final LayoutInflater mLayoutInflater; + private final boolean mShowAzLabelIfPoss; private DirectShareViewHolder mDirectShareViewHolder; private int mChooserTargetWidth = 0; - private final boolean mShowAzLabelIfPoss; - private boolean mLayoutRequested = false; private int mFooterHeight = 0; @@ -2947,22 +2949,6 @@ public class ChooserActivity extends ResolverActivity implements return false; } - /** - * Hides the list item content preview. - *

    Not to be confused with the sticky content preview which is above the - * personal and work tabs. - */ - public void hideContentPreview() { - mLayoutRequested = true; - notifyDataSetChanged(); - } - - public boolean consumeLayoutRequest() { - boolean oldValue = mLayoutRequested; - mLayoutRequested = false; - return oldValue; - } - public int getRowCount() { return (int) ( getSystemRowCount() -- cgit v1.2.3-59-g8ed1b From 0c7df5fef314e451cac57521b10e5f34cc269d92 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 6 Oct 2022 18:40:29 -0700 Subject: Simplify SelectableTargetInfo dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove “functional” (Context, PackageManager, and SelectableTargetInfoCommunicator) and obsolete dependencies from SelectableTargetInfo. Changes to SelectableTargetInfo: 1. Fields mBadgeIcon, mBadgeContentDescriptor were never read and thus removed. 2. Values previously provided by SelectableTargetInfoCommunicator are inlined or pre-calculated: - getTargetIntent() used in the resolved intent calculation replaced with a pre-calculated resolved intent value (we can do it as target intent does not change); - getReferrerFillInIntent() passed as an argument (we can do it as the value does not change). 3. As ChooserListAdapter.LoadDirectShareIconTask was the only place that invoked SelectableTargetInfo#loadIcon(), icon loading logic, SelectableTargetInfo#getChooserTargetIconDrawable method, is moved over there and the related code is deleted from SelectableTargetInfo. 4. SelectableTargetInfo.SelectableTargetInfoCommunicator #makePresentatinGetter() removed as not used. Changes to TargetInfo (and related classes): 1. TargetInfo#setDrawableIcon() is added to the interface as a way for ChooserListAdapter#LoadDirectShareIconTask to update the icon. 2. NotSelectableTargetInfo#newPlaceHolderInfo() changed to receive a context that would be used by the target it creates. 3. After the aforementioned changes no implementation of the TargetInfo#getDisplayIcon actually uses its Context argument, thus it is deleted. 4. A default implementation added for TargetInfo#hasDisplayIcon method as all implementations, essentially, were the same. 5. TargetInfo#loadIcon removed as not used. Fix: 257285229 Test: manual functinality test Test: atest IntentResolverUnitTests Change-Id: I448ebed9c5346092ebca6c4e356830c55288d55b --- .../android/intentresolver/ChooserActivity.java | 10 +- .../android/intentresolver/ChooserListAdapter.java | 89 +++++++-- .../intentresolver/ResolverListAdapter.java | 2 +- .../intentresolver/ShortcutSelectionLogic.java | 39 +++- .../intentresolver/chooser/DisplayResolveInfo.java | 17 +- .../chooser/NotSelectableTargetInfo.java | 11 +- .../chooser/SelectableTargetInfo.java | 212 +++++---------------- .../android/intentresolver/chooser/TargetInfo.java | 17 +- .../intentresolver/ChooserListAdapterTest.kt | 31 ++- .../intentresolver/ShortcutSelectionLogicTest.kt | 28 +-- .../intentresolver/chooser/TargetInfoTest.kt | 72 +++++-- 11 files changed, 253 insertions(+), 275 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3ccefe1b..938fbb0d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -115,7 +115,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -1128,7 +1127,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(this), + ti.getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via nearby @@ -1151,7 +1150,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(this), + ti.getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via edit @@ -2457,11 +2456,6 @@ public class ChooserActivity extends ResolverActivity implements super.onHandlePackagesChanged(listAdapter); } - @Override // SelectableTargetInfoCommunicator - public ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info) { - return mChooserMultiProfilePagerAdapter.getActiveListAdapter().makePresentationGetter(info); - } - @Override // SelectableTargetInfoCommunicator public Intent getReferrerFillInIntent() { return mReferrerFillInIntent; diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 92cd0043..f20ee38f 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -27,10 +27,14 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; +import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; import android.os.AsyncTask; import android.os.Trace; import android.os.UserManager; @@ -42,10 +46,13 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; @@ -84,8 +91,7 @@ public class ChooserListAdapter extends ResolverListAdapter { private final Map mIconLoaders = new HashMap<>(); // Reserve spots for incoming direct share targets by adding placeholders - private TargetInfo mPlaceHolderTargetInfo = - NotSelectableTargetInfo.newPlaceHolderTargetInfo(); + private final TargetInfo mPlaceHolderTargetInfo; private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); @@ -142,6 +148,7 @@ public class ChooserListAdapter extends ResolverListAdapter { resolverListController, chooserListCommunicator, false); mChooserListCommunicator = chooserListCommunicator; + mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); createPlaceHolders(); mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator; mChooserActivityLogger = chooserActivityLogger; @@ -256,7 +263,7 @@ public class ChooserListAdapter extends ResolverListAdapter { extendedInfo != null ? extendedInfo : "", appName); holder.updateContentDescription(contentDescription); if (!info.hasDisplayIcon()) { - loadDirectShareIcon(info); + loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { DisplayResolveInfo dri = (DisplayResolveInfo) info; @@ -302,7 +309,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - private void loadDirectShareIcon(TargetInfo info) { + private void loadDirectShareIcon(SelectableTargetInfo info) { LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); if (task == null) { task = createLoadDirectShareIconTask(info); @@ -312,8 +319,10 @@ public class ChooserListAdapter extends ResolverListAdapter { } @VisibleForTesting - protected LoadDirectShareIconTask createLoadDirectShareIconTask(TargetInfo info) { - return new LoadDirectShareIconTask(info); + protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) { + return new LoadDirectShareIconTask( + mContext.createContextAsUser(getUserHandle(), 0), + info); } void updateAlphabeticalList() { @@ -545,7 +554,8 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mSelectableTargetInfoCommunicator, + mSelectableTargetInfoCommunicator.getTargetIntent(), + mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), mChooserListCommunicator.getMaxRankedTargets(), mServiceTargets); if (isUpdated) { @@ -641,25 +651,76 @@ public class ChooserListAdapter extends ResolverListAdapter { * Loads direct share targets icons. */ @VisibleForTesting - public class LoadDirectShareIconTask extends AsyncTask { - private final TargetInfo mTargetInfo; + public class LoadDirectShareIconTask extends AsyncTask { + private final Context mContext; + private final SelectableTargetInfo mTargetInfo; - private LoadDirectShareIconTask(TargetInfo targetInfo) { + private LoadDirectShareIconTask(Context context, SelectableTargetInfo targetInfo) { + mContext = context; mTargetInfo = targetInfo; } @Override - protected Boolean doInBackground(Void... voids) { - return mTargetInfo.loadIcon(); + protected Drawable doInBackground(Void... voids) { + return getChooserTargetIconDrawable( + mContext, + mTargetInfo.getChooserTargetIcon(), + mTargetInfo.getChooserTargetComponentName(), + mTargetInfo.getDirectShareShortcutInfo()); } @Override - protected void onPostExecute(Boolean isLoaded) { - if (isLoaded) { + protected void onPostExecute(@Nullable Drawable icon) { + if (icon != null && !mTargetInfo.hasDisplayIcon()) { + mTargetInfo.setDisplayIcon(icon); notifyDataSetChanged(); } } + @WorkerThread + private Drawable getChooserTargetIconDrawable( + Context context, + @Nullable Icon icon, + ComponentName targetComponentName, + @Nullable ShortcutInfo shortcutInfo) { + Drawable directShareIcon = null; + + // First get the target drawable and associated activity info + if (icon != null) { + directShareIcon = icon.loadDrawable(context); + } else if (shortcutInfo != null) { + LauncherApps launcherApps = context.getSystemService(LauncherApps.class); + if (launcherApps != null) { + directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); + } + } + + if (directShareIcon == null) { + return null; + } + + ActivityInfo info = null; + try { + info = context.getPackageManager().getActivityInfo(targetComponentName, 0); + } catch (PackageManager.NameNotFoundException error) { + Log.e(TAG, "Could not find activity associated with ChooserTarget"); + } + + if (info == null) { + return null; + } + + // Now fetch app icon and raster with no badging even in work profile + Bitmap appIcon = makePresentationGetter(info).getIconBitmap(null); + + // Raster target drawable with appIcon as a badge + SimpleIconFactory sif = SimpleIconFactory.obtain(context); + Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); + sif.recycle(); + + return new BitmapDrawable(context.getResources(), directShareBadgedIcon); + } + /** * An alias for execute to use with unit tests. */ diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 63da842d..f74c33c0 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -921,7 +921,7 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIcon(itemView.getContext())); + icon.setImageDrawable(info.getDisplayIcon()); if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 39187bdb..645b9391 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -19,13 +19,15 @@ package com.android.intentresolver; import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; import android.util.Log; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import java.util.Collections; @@ -65,7 +67,8 @@ class ShortcutSelectionLogic { Map directShareToShortcutInfos, Map directShareToAppTargets, Context userContext, - SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator, + Intent targetIntent, + Intent referrerFillInIntent, int maxRankedTargets, List serviceTargets) { if (DEBUG) { @@ -100,15 +103,28 @@ class ShortcutSelectionLogic { if ((shortcutInfo != null) && shortcutInfo.isPinned()) { targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; } + ResolveInfo backupResolveInfo; + Intent resolvedIntent; + if (origTarget == null) { + resolvedIntent = createResolvedIntentForCallerTarget(target, targetIntent); + backupResolveInfo = userContext.getPackageManager() + .resolveActivity( + resolvedIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); + } else { + resolvedIntent = origTarget.getResolvedIntent(); + backupResolveInfo = null; + } boolean isInserted = insertServiceTarget( SelectableTargetInfo.newSelectableTargetInfo( - userContext, origTarget, + backupResolveInfo, + resolvedIntent, target, targetScore, - mSelectableTargetInfoCommunicator, shortcutInfo, - directShareToAppTargets.get(target)), + directShareToAppTargets.get(target), + referrerFillInIntent), maxRankedTargets, serviceTargets); @@ -128,6 +144,19 @@ class ShortcutSelectionLogic { return shouldNotify; } + /** + * Creates a resolved intent for a caller-specified target. + * @param target, a caller-specified target. + * @param targetIntent, a target intent for the Chooser (see {@link Intent#EXTRA_INTENT}). + */ + private static Intent createResolvedIntentForCallerTarget( + ChooserTarget target, Intent targetIntent) { + final Intent resolvedIntent = new Intent(targetIntent); + resolvedIntent.setComponent(target.getComponentName()); + resolvedIntent.putExtras(target.getIntentExtras()); + return resolvedIntent; + } + private boolean insertServiceTarget( TargetInfo chooserTargetInfo, int maxRankedTargets, diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index daa69152..16dd28bc 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -20,7 +20,6 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; -import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; @@ -47,7 +46,7 @@ public class DisplayResolveInfo implements TargetInfo { private CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List mSourceIntents = new ArrayList<>(); - private boolean mIsSuspended; + private final boolean mIsSuspended; private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; private boolean mPinned = false; @@ -107,10 +106,14 @@ public class DisplayResolveInfo implements TargetInfo { } - private DisplayResolveInfo(DisplayResolveInfo other, Intent fillInIntent, int flags, + private DisplayResolveInfo( + DisplayResolveInfo other, + Intent fillInIntent, + int flags, ResolveInfoPresentationGetter resolveInfoPresentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; + mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; @@ -122,6 +125,7 @@ public class DisplayResolveInfo implements TargetInfo { protected DisplayResolveInfo(DisplayResolveInfo other) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; + mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; @@ -158,7 +162,8 @@ public class DisplayResolveInfo implements TargetInfo { mExtendedInfo = extendedInfo; } - public Drawable getDisplayIcon(Context context) { + @Override + public Drawable getDisplayIcon() { return mDisplayIcon; } @@ -185,10 +190,6 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayIcon = icon; } - public boolean hasDisplayIcon() { - return mDisplayIcon != null; - } - public CharSequence getExtendedInfo() { return mExtendedInfo; } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 8ec52c8a..3b4b89b1 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -45,14 +45,9 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { } @Override - public Drawable getDisplayIcon(Context context) { + public Drawable getDisplayIcon() { return null; } - - @Override - public boolean hasDisplayIcon() { - return false; - } }; } @@ -60,7 +55,7 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { * Create a non-selectable {@link TargetInfo} with placeholder content to be displayed * unless/until it can be replaced by the result of a pending asynchronous load. */ - public static TargetInfo newPlaceHolderTargetInfo() { + public static TargetInfo newPlaceHolderTargetInfo(Context context) { return new NotSelectableTargetInfo() { @Override public boolean isPlaceHolderTargetInfo() { @@ -68,7 +63,7 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { } @Override - public Drawable getDisplayIcon(Context context) { + public Drawable getDisplayIcon() { AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); avd.start(); // Start animation after generation. diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 7e6e49fb..093020b8 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -22,14 +22,8 @@ import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.LauncherApps; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Bundle; @@ -42,9 +36,6 @@ import android.util.Log; import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; -import com.android.intentresolver.SimpleIconFactory; -import com.android.internal.annotations.GuardedBy; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; @@ -65,84 +56,74 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, DEFAULT_SALT_EXPIRATION_DAYS); - private final Context mContext; @Nullable private final DisplayResolveInfo mSourceInfo; + @Nullable private final ResolveInfo mBackupResolveInfo; + private final Intent mResolvedIntent; private final ChooserTarget mChooserTarget; private final String mDisplayLabel; - private final PackageManager mPm; - private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; @Nullable private final AppTarget mAppTarget; @Nullable private final ShortcutInfo mShortcutInfo; + + /** + * A refinement intent from the caller, if any (see + * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER}) + */ private final Intent mFillInIntent; + + /** + * An intent containing referrer URI (see {@link Activity#getReferrer()} (possibly {@code null}) + * in its extended data under the key {@link Intent#EXTRA_REFERRER}. + */ + private final Intent mReferrerFillInIntent; private final int mFillInFlags; private final boolean mIsPinned; private final float mModifiedScore; - private final boolean mIsSuspended; - private final Drawable mBadgeIcon; - private final CharSequence mBadgeContentDescription; - @GuardedBy("this") private Drawable mDisplayIcon; - @GuardedBy("this") - private boolean mHasAttemptedIconLoad; - /** Create a new {@link TargetInfo} instance representing a selectable target. */ public static TargetInfo newSelectableTargetInfo( - Context context, @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, ChooserTarget chooserTarget, float modifiedScore, - SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, @Nullable ShortcutInfo shortcutInfo, - @Nullable AppTarget appTarget) { + @Nullable AppTarget appTarget, + Intent referrerFillInIntent) { return new SelectableTargetInfo( - context, sourceInfo, + backupResolveInfo, + resolvedIntent, chooserTarget, modifiedScore, - selectableTargetInfoCommunicator, shortcutInfo, - appTarget); + appTarget, + referrerFillInIntent); } private SelectableTargetInfo( - Context context, @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, ChooserTarget chooserTarget, float modifiedScore, - SelectableTargetInfoCommunicator selectableTargetInfoComunicator, @Nullable ShortcutInfo shortcutInfo, - @Nullable AppTarget appTarget) { - mContext = context; + @Nullable AppTarget appTarget, + Intent referrerFillInIntent) { mSourceInfo = sourceInfo; mChooserTarget = chooserTarget; mModifiedScore = modifiedScore; - mPm = mContext.getPackageManager(); - mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator; mShortcutInfo = shortcutInfo; mAppTarget = appTarget; mIsPinned = shortcutInfo != null && shortcutInfo.isPinned(); - - final PackageManager pm = mContext.getPackageManager(); - final ApplicationInfo applicationInfo = getApplicationInfoFromSource(sourceInfo); - - mBadgeIcon = (applicationInfo == null) ? null : pm.getApplicationIcon(applicationInfo); - mBadgeContentDescription = - (applicationInfo == null) ? null : pm.getApplicationLabel(applicationInfo); - mIsSuspended = (applicationInfo != null) - && ((applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0); - - if (sourceInfo != null) { - mBackupResolveInfo = null; - } else { - mBackupResolveInfo = - mContext.getPackageManager().resolveActivity(getResolvedIntent(), 0); - } + mBackupResolveInfo = backupResolveInfo; + mResolvedIntent = resolvedIntent; + mReferrerFillInIntent = referrerFillInIntent; mFillInIntent = null; mFillInFlags = 0; @@ -151,25 +132,18 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { } private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { - mContext = other.mContext; - mPm = other.mPm; - mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator; mSourceInfo = other.mSourceInfo; mBackupResolveInfo = other.mBackupResolveInfo; + mResolvedIntent = other.mResolvedIntent; mChooserTarget = other.mChooserTarget; - mBadgeIcon = other.mBadgeIcon; - mBadgeContentDescription = other.mBadgeContentDescription; mShortcutInfo = other.mShortcutInfo; mAppTarget = other.mAppTarget; - mIsSuspended = other.mIsSuspended; - synchronized (other) { - mDisplayIcon = other.mDisplayIcon; - mHasAttemptedIconLoad = other.mHasAttemptedIconLoad; - } + mDisplayIcon = other.mDisplayIcon; mFillInIntent = fillInIntent; mFillInFlags = flags; mModifiedScore = other.mModifiedScore; mIsPinned = other.mIsPinned; + mReferrerFillInIntent = other.mReferrerFillInIntent; mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); } @@ -181,7 +155,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public boolean isSuspended() { - return mIsSuspended; + return (mSourceInfo != null) && mSourceInfo.isSuspended(); } @Override @@ -190,79 +164,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mSourceInfo; } - /** - * Load display icon, if needed. - */ - @Override - public boolean loadIcon() { - synchronized (this) { - // TODO: evaluating these conditions while `synchronized` ensures that we get consistent - // reads between `mDisplayIcon` and `mHasAttemptedIconLoad`, but doesn't otherwise - // prevent races where two threads might check the conditions (in synchrony) and then - // both go on to load the icon (in parallel, even though one of the loads would be - // redundant, and even though we have no logic to decide which result to keep if they - // differ). This is probably a "safe optimization" in some cases, but our correctness - // can't rely on this eliding the duplicate load, and with a more careful design we - // could probably optimize it out in more cases (or else maybe we should get rid of - // this complexity altogether). - if ((mDisplayIcon != null) || (mShortcutInfo == null) || mHasAttemptedIconLoad) { - return false; - } - } - - Drawable icon = getChooserTargetIconDrawable(mChooserTarget, mShortcutInfo); - if (icon == null) { - return false; - } - - synchronized (this) { - mDisplayIcon = icon; - // TODO: we only end up setting `mHasAttemptedIconLoad` if we were successful in loading - // a (non-null) display icon; in that case, our guard clause above will already - // early-return `false` regardless of `mHasAttemptedIconLoad`. This should be refined, - // or removed if we don't need the extra complexity (including the synchronizaiton?). - mHasAttemptedIconLoad = true; - } - return true; - } - - private Drawable getChooserTargetIconDrawable(ChooserTarget target, - @Nullable ShortcutInfo shortcutInfo) { - Drawable directShareIcon = null; - - // First get the target drawable and associated activity info - final Icon icon = target.getIcon(); - if (icon != null) { - directShareIcon = icon.loadDrawable(mContext); - } else if (shortcutInfo != null) { - LauncherApps launcherApps = (LauncherApps) mContext.getSystemService( - Context.LAUNCHER_APPS_SERVICE); - directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); - } - - if (directShareIcon == null) return null; - - ActivityInfo info = null; - try { - info = mPm.getActivityInfo(target.getComponentName(), 0); - } catch (PackageManager.NameNotFoundException error) { - Log.e(TAG, "Could not find activity associated with ChooserTarget"); - } - - if (info == null) return null; - - // Now fetch app icon and raster with no badging even in work profile - Bitmap appIcon = mSelectableTargetInfoCommunicator.makePresentationGetter(info) - .getIconBitmap(null); - - // Raster target drawable with appIcon as a badge - SimpleIconFactory sif = SimpleIconFactory.obtain(mContext); - Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); - sif.recycle(); - - return new BitmapDrawable(mContext.getResources(), directShareBadgedIcon); - } - @Override public float getModifiedScore() { return mModifiedScore; @@ -270,14 +171,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public Intent getResolvedIntent() { - if (mSourceInfo != null) { - return mSourceInfo.getResolvedIntent(); - } - - final Intent targetIntent = new Intent(mSelectableTargetInfoCommunicator.getTargetIntent()); - targetIntent.setComponent(mChooserTarget.getComponentName()); - targetIntent.putExtras(mChooserTarget.getIntentExtras()); - return targetIntent; + return mResolvedIntent; } @Override @@ -296,6 +190,11 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mChooserTarget.getComponentName(); } + @Nullable + public Icon getChooserTargetIcon() { + return mChooserTarget.getIcon(); + } + private Intent getBaseIntentToSend() { Intent result = getResolvedIntent(); if (result == null) { @@ -305,7 +204,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { if (mFillInIntent != null) { result.fillIn(mFillInIntent, mFillInFlags); } - result.fillIn(mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), 0); + result.fillIn(mReferrerFillInIntent, 0); } return result; } @@ -362,16 +261,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { } @Override - public synchronized Drawable getDisplayIcon(Context context) { + public Drawable getDisplayIcon() { return mDisplayIcon; } - /** - * @return true if display icon is available - */ - @Override - public synchronized boolean hasDisplayIcon() { - return mDisplayIcon != null; + public void setDisplayIcon(Drawable icon) { + mDisplayIcon = icon; } @Override @@ -418,40 +313,19 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mMaxHashSaltDays); } - @Nullable - private static ApplicationInfo getApplicationInfoFromSource( - @Nullable DisplayResolveInfo sourceInfo) { - if (sourceInfo == null) { - return null; - } - - final ResolveInfo resolveInfo = sourceInfo.getResolveInfo(); - if (resolveInfo == null) { - return null; - } - - final ActivityInfo activityInfo = resolveInfo.activityInfo; - if (activityInfo == null) { - return null; - } - - return activityInfo.applicationInfo; - } - private static String sanitizeDisplayLabel(CharSequence label) { SpannableStringBuilder sb = new SpannableStringBuilder(label); sb.clearSpans(); return sb.toString(); } + // TODO: merge into ChooserListAdapter.ChooserListCommunicator and delete. /** * Necessary methods to communicate between {@link SelectableTargetInfo} * and {@link ResolverActivity} or {@link ChooserActivity}. */ public interface SelectableTargetInfoCommunicator { - ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info); - Intent getTargetIntent(); Intent getReferrerFillInIntent(); diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 46cd53c6..0e100d4f 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -136,14 +136,16 @@ public interface TargetInfo { /** * @return The drawable that should be used to represent this target including badge - * @param context */ - Drawable getDisplayIcon(Context context); + @Nullable + Drawable getDisplayIcon(); /** * @return true if display icon is available. */ - boolean hasDisplayIcon(); + default boolean hasDisplayIcon() { + return getDisplayIcon() != null; + } /** * Clone this target with the given fill-in information. */ @@ -256,15 +258,6 @@ public interface TargetInfo { return null; } - /** - * Attempt to load the display icon, if we have the info for one but it hasn't been loaded yet. - * @return true if an icon may have been loaded as the result of this operation, potentially - * prompting a UI refresh. If this returns false, clients can safely assume there was no change. - */ - default boolean loadIcon() { - return false; - } - /** * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available. * TODO: this seems to return non-null only for ChooserTargetInfo subclasses. Determine the diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 6ca7c5d1..c43b014c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -17,10 +17,9 @@ package com.android.intentresolver import android.content.ComponentName +import android.content.Intent import android.content.pm.PackageManager import android.content.pm.PackageManager.ResolveInfoFlags -import android.os.Bundle -import android.service.chooser.ChooserTarget import android.view.View import android.widget.FrameLayout import android.widget.ImageView @@ -71,7 +70,7 @@ class ChooserListAdapterTest { chooserActivityLogger, ) { override fun createLoadDirectShareIconTask( - info: TargetInfo? + info: SelectableTargetInfo ): LoadDirectShareIconTask = taskProvider(info) } @@ -122,22 +121,16 @@ class ChooserListAdapterTest { private fun createSelectableTargetInfo(): TargetInfo = SelectableTargetInfo.newSelectableTargetInfo( - context, - null, - createChooserTarget(), - 1f, - selectableTargetInfoCommunicator, - null, - null - ) - - private fun createChooserTarget(): ChooserTarget = - ChooserTarget( - "Title", - null, - 1f, - ComponentName("package", "package.Class"), - Bundle() + /* sourceInfo = */ mock(), + /* backupResolveInfo = */ mock(), + /* resolvedIntent = */ Intent(), + /* chooserTarget = */ createChooserTarget( + "Target", 0.5f, ComponentName("pkg", "Class"), "id-1" + ), + /* modifiedScore = */ 1f, + /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1), + /* appTarget */ null, + /* referrerFillInIntent = */ Intent() ) private fun createView(): View { diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 8581ed0c..2c56e613 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -18,11 +18,10 @@ package com.android.intentresolver import android.content.ComponentName import android.content.Context -import android.content.Intent import android.content.pm.ShortcutInfo import android.service.chooser.ChooserTarget import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator +import androidx.test.filters.SmallTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -31,6 +30,7 @@ private const val PACKAGE_A = "package.a" private const val PACKAGE_B = "package.b" private const val CLASS_NAME = "./MainActivity" +@SmallTest class ShortcutSelectionLogicTest { private val packageTargets = HashMap>().apply { arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg -> @@ -68,7 +68,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) @@ -99,7 +100,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) @@ -130,7 +132,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 1, /* serviceTargets = */ serviceResults ) @@ -163,7 +166,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) @@ -175,7 +179,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) @@ -211,7 +216,8 @@ class ShortcutSelectionLogicTest { ), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ mock(), - /* mSelectableTargetInfoCommunicator = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) @@ -234,9 +240,6 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ true ) - val targetInfoCommunicator = mock { - whenever(targetIntent).thenReturn(Intent()) - } val context = mock { whenever(packageManager).thenReturn(mock()) } @@ -249,7 +252,8 @@ class ShortcutSelectionLogicTest { /* directShareToShortcutInfos = */ emptyMap(), /* directShareToAppTargets = */ emptyMap(), /* userContext = */ context, - /* mSelectableTargetInfoCommunicator = */ targetInfoCommunicator, + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, /* serviceTargets = */ serviceResults ) diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index ae9c0f8d..11837e08 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -18,16 +18,16 @@ package com.android.intentresolver.chooser import android.app.prediction.AppTarget import android.app.prediction.AppTargetId +import android.content.ComponentName import android.content.Intent -import android.content.pm.ShortcutInfo +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo import android.os.UserHandle -import android.service.chooser.ChooserTarget import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.ResolverDataProvider -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -40,12 +40,12 @@ class TargetInfoTest { assertThat(info.isEmptyTargetInfo()).isTrue() assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isFalse() - assertThat(info.getDisplayIcon(context)).isNull() + assertThat(info.getDisplayIcon()).isNull() } @Test fun testNewPlaceholderTargetInfo() { - val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo() + val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) assertThat(info.isPlaceHolderTargetInfo()).isTrue() assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isTrue() @@ -57,33 +57,67 @@ class TargetInfoTest { val displayInfo: DisplayResolveInfo = mock() val chooserTarget = createChooserTarget( "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") - val selectableTargetInfoCommunicator: SelectableTargetInfoCommunicator = mock() val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) val appTarget = AppTarget( AppTargetId("id"), - chooserTarget.getComponentName().getPackageName(), - chooserTarget.getComponentName().getClassName(), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, UserHandle.CURRENT) + val resolvedIntent = mock() val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - context, displayInfo, + mock(), + resolvedIntent, chooserTarget, 0.1f, - selectableTargetInfoCommunicator, shortcutInfo, - appTarget) - assertThat(targetInfo.isSelectableTargetInfo()).isTrue() - assertThat(targetInfo.isChooserTargetInfo()).isTrue() // From legacy inheritance model. - assertThat(targetInfo.getDisplayResolveInfo()).isSameInstanceAs(displayInfo) - assertThat(targetInfo.getChooserTargetComponentName()) - .isEqualTo(chooserTarget.getComponentName()) - assertThat(targetInfo.getDirectShareShortcutId()).isEqualTo(shortcutInfo.getId()) - assertThat(targetInfo.getDirectShareShortcutInfo()).isSameInstanceAs(shortcutInfo) - assertThat(targetInfo.getDirectShareAppTarget()).isSameInstanceAs(appTarget) + appTarget, + mock(), + ) + assertThat(targetInfo.isSelectableTargetInfo).isTrue() + assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(displayInfo) + assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) + assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) + assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo) + assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget) + assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent) // TODO: make more meaningful assertions about the behavior of a selectable target. } + @Test + fun test_SelectableTargetInfo_componentName_no_source_info() { + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT) + val pkgName = "org.package" + val className = "MainActivity" + val backupResolveInfo = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = pkgName + name = className + } + } + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + null, + backupResolveInfo, + mock(), + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) + assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) + } + @Test fun testNewDisplayResolveInfo() { val intent = Intent(Intent.ACTION_SEND) -- cgit v1.2.3-59-g8ed1b From 34ec7d2e9baa86b434ca3506c31257887a198c5b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 31 Oct 2022 14:00:37 -0700 Subject: Remove SelectableTargetInfo#SelectableTargetInfoCommunicator interface Both SelectableTargetInfo#SelectableTargetInfoCommunicator and ChooserListAdapter#ChooserListCommunicator are used and implemented by the same classes: ChooserListAdapter and ChooserActivity respectively. Fix: 257483782 Test: manual test Test: atest IntentResolverUnitTests Change-Id: Ia0f5e0637c63cdf3e7b53f9e77c4b94dea81475c --- java/src/com/android/intentresolver/ChooserActivity.java | 16 ++++++++++------ .../com/android/intentresolver/ChooserListAdapter.java | 12 ++++++------ .../intentresolver/chooser/SelectableTargetInfo.java | 13 ------------- .../com/android/intentresolver/ChooserListAdapterTest.kt | 7 +------ .../android/intentresolver/ChooserWrapperActivity.java | 12 +++++++++--- 5 files changed, 26 insertions(+), 34 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 938fbb0d..5d9c1c3c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -118,7 +118,6 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -153,8 +152,7 @@ import java.util.function.Supplier; * */ public class ChooserActivity extends ResolverActivity implements - ChooserListAdapter.ChooserListCommunicator, - SelectableTargetInfoCommunicator { + ChooserListAdapter.ChooserListCommunicator { private static final String TAG = "ChooserActivity"; private boolean mShouldDisplayLandscape; @@ -2222,9 +2220,15 @@ public class ChooserActivity extends ResolverActivity implements public ChooserListAdapter createChooserListAdapter(Context context, List payloadIntents, Intent[] initialIntents, List rList, boolean filterLastUsed, ResolverListController resolverListController) { - return new ChooserListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, resolverListController, this, - this, context.getPackageManager(), + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + this, + context.getPackageManager(), getChooserActivityLogger()); } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index f20ee38f..e31bf2ab 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -53,7 +53,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -85,7 +84,6 @@ public class ChooserListAdapter extends ResolverListAdapter { public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; private final ChooserListCommunicator mChooserListCommunicator; - private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; private final ChooserActivityLogger mChooserActivityLogger; private final Map mIconLoaders = new HashMap<>(); @@ -139,7 +137,6 @@ public class ChooserListAdapter extends ResolverListAdapter { boolean filterLastUsed, ResolverListController resolverListController, ChooserListCommunicator chooserListCommunicator, - SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, PackageManager packageManager, ChooserActivityLogger chooserActivityLogger) { // Don't send the initial intents through the shared ResolverActivity path, @@ -150,7 +147,6 @@ public class ChooserListAdapter extends ResolverListAdapter { mChooserListCommunicator = chooserListCommunicator; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); createPlaceHolders(); - mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator; mChooserActivityLogger = chooserActivityLogger; mShortcutSelectionLogic = new ShortcutSelectionLogic( context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp), @@ -554,8 +550,8 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mSelectableTargetInfoCommunicator.getTargetIntent(), - mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), + mChooserListCommunicator.getTargetIntent(), + mChooserListCommunicator.getReferrerFillInIntent(), mChooserListCommunicator.getMaxRankedTargets(), mServiceTargets); if (isUpdated) { @@ -645,6 +641,10 @@ public class ChooserListAdapter extends ResolverListAdapter { int getMaxRankedTargets(); boolean isSendAction(Intent targetIntent); + + Intent getTargetIntent(); + + Intent getReferrerFillInIntent(); } /** diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 093020b8..a9a45a53 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -34,7 +34,6 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; -import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.ResolverActivity; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -318,16 +317,4 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { sb.clearSpans(); return sb.toString(); } - - // TODO: merge into ChooserListAdapter.ChooserListCommunicator and delete. - /** - * Necessary methods to communicate between {@link SelectableTargetInfo} - * and {@link ResolverActivity} or {@link ChooserActivity}. - */ - public interface SelectableTargetInfoCommunicator { - - Intent getTargetIntent(); - - Intent getReferrerFillInIntent(); - } } diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index c43b014c..bcb6c240 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -28,7 +28,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask import com.android.intentresolver.chooser.SelectableTargetInfo -import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator import com.android.intentresolver.chooser.TargetInfo import com.android.internal.R import org.junit.Before @@ -48,11 +47,8 @@ class ChooserListAdapterTest { private val resolverListController = mock() private val chooserListCommunicator = mock { whenever(maxRankedTargets).thenReturn(0) + whenever(targetIntent).thenReturn(mock()) } - private val selectableTargetInfoCommunicator = - mock { - whenever(targetIntent).thenReturn(mock()) - } private val chooserActivityLogger = mock() private fun createChooserListAdapter( @@ -65,7 +61,6 @@ class ChooserListAdapterTest { false, resolverListController, chooserListCommunicator, - selectableTargetInfoCommunicator, packageManager, chooserActivityLogger, ) { diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 079fbb9d..c1d20b44 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -80,9 +80,15 @@ public class ChooserWrapperActivity PackageManager packageManager = sOverrides.packageManager == null ? context.getPackageManager() : sOverrides.packageManager; - return new ChooserListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, resolverListController, - this, this, packageManager, + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + this, + packageManager, getChooserActivityLogger()); } -- cgit v1.2.3-59-g8ed1b From edc3a62badfb2de42438d3501712ec286f2d1360 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sat, 10 Sep 2022 21:44:34 -0700 Subject: Enable Chooser test. Enable ChooserActivity test that involves clicking on a shortcut. An adaptration of the test from the core module. Bug: 215699869 Test: atest IntentResolverUnitTests Change-Id: If204d8ef0db98776089d9d79113ce9b4b72eea2d --- .../android/intentresolver/ChooserActivity.java | 3 +- .../ChooserActivityOverrideData.java | 6 +++ .../intentresolver/ChooserWrapperActivity.java | 15 ++++++- .../UnbundledChooserActivityTest.java | 50 ++++++++++++++-------- 4 files changed, 53 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 938fbb0d..bbe68ac0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2512,8 +2512,9 @@ public class ChooserActivity extends ResolverActivity implements queryDirectShareTargets(chooserListAdapter, false); } + @VisibleForTesting @MainThread - private void onShortcutsLoaded( + protected void onShortcutsLoaded( ChooserListAdapter adapter, int targetType, List resultInfos) { UserHandle userHandle = adapter.getUserHandle(); if (DEBUG) { diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index d1ca2b09..e474938b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -24,11 +24,13 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; +import android.util.Pair; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; /** @@ -50,6 +52,9 @@ public class ChooserActivityOverrideData { public Function createPackageManager; public Function onSafelyStartCallback; public Function onQueryDirectShareTargets; + public BiFunction< + IChooserWrapper, ChooserListAdapter, Pair> + directShareTargets; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; @@ -72,6 +77,7 @@ public class ChooserActivityOverrideData { public void reset() { onSafelyStartCallback = null; onQueryDirectShareTargets = null; + directShareTargets = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 079fbb9d..b957bb9d 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -31,6 +31,7 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; +import android.util.Pair; import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter; @@ -46,6 +47,7 @@ import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import java.util.Arrays; import java.util.List; /** @@ -248,8 +250,17 @@ public class ChooserWrapperActivity } @Override - protected void queryDirectShareTargets(ChooserListAdapter adapter, - boolean skipAppPredictionService) { + protected void queryDirectShareTargets( + ChooserListAdapter adapter, boolean skipAppPredictionService) { + if (sOverrides.directShareTargets != null) { + Pair result = + sOverrides.directShareTargets.apply(this, adapter); + // Imitate asynchronous shortcut loading + getMainExecutor().execute( + () -> onShortcutsLoaded( + adapter, result.first, Arrays.asList(result.second))); + return; + } if (sOverrides.onQueryDirectShareTargets != null) { sOverrides.onQueryDirectShareTargets.apply(adapter); } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index dfdbeda7..7c304284 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -48,6 +48,7 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; @@ -81,6 +82,7 @@ import android.net.Uri; import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; +import android.util.Pair; import android.view.View; import androidx.annotation.CallSuper; @@ -91,6 +93,7 @@ import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; +import com.android.intentresolver.ChooserActivity.ServiceResultInfo; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -1929,7 +1932,7 @@ public class UnbundledChooserActivityTest { // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. } - @Test @Ignore + @Test public void testDirectTargetLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1950,27 +1953,32 @@ public class UnbundledChooserActivityTest { resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + ChooserActivityOverrideData + .getInstance() + .directShareTargets = (activity, adapter) -> { + DisplayResolveInfo displayInfo = activity.createTestDisplayResolveInfo( + sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null); + ServiceResultInfo[] results = { + new ServiceResultInfo(displayInfo, serviceTargets) }; + // TODO: consider covering the other type. + // Only 2 types are expected out of the shortcut loading logic: + // - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, if shortcuts were loaded from + // the ShortcutManager, and; + // - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, if shortcuts were loaded + // from AppPredictor. + // Ideally, our tests should cover all of them. + return new Pair<>(TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, results); + }; + // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) - ); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", @@ -1983,6 +1991,12 @@ public class UnbundledChooserActivityTest { onView(withText(name)) .perform(click()); waitForIdle(); + + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(Integer.class); + Mockito.verify(logger, times(1)) + .logShareTargetSelected(typeCaptor.capture(), any(), anyInt(), anyBoolean()); + assertThat(typeCaptor.getValue(), is(ChooserActivity.SELECTION_TYPE_SERVICE)); } @Test @Ignore -- cgit v1.2.3-59-g8ed1b From 9c8893a5983988c9127f3ae846ea212593777466 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 7 Nov 2022 12:35:20 -0500 Subject: Cache ChooseTarget fields in SelectableTargetInfo. As of ag/20378152, `ChooserTarget` is no longer part of the (consumer-side) public API of `SelectableTargetInfo`; it's only used as a container for some initialization data that's maintained as an implementation detail internal to `SelectableTargetInfo`. In this CL, the relevant `ChooserTarget` data is extracted at initialization time, and subsequently the `SelectableTargetInfo` no longer retains a reference to any `ChooserTarget` instance after it's constructed. In a subsequent cleanup CL, the `SelectableTargetInfo` constructor signature can be reworked to take these fields directly, instead of via a `ChooserTarget`. The factory method takes over responsibility for extracting the fields from a `ChooserTarget`, or another overload allows clients to skip the `ChooserTarget` representation and provide the values directly. We'll stop synthesizing `ChooserTarget` intermediate representations for the targets we inject ourselves (instead just passing the values to the new overload), and we'll operate on the deprecated `ChooserTarget` type only for the single step of immediately converting targets passed in by the caller intent. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ibf97c1cebce292f1e2f02def1924bd180ec70077 --- .../chooser/SelectableTargetInfo.java | 36 ++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 093020b8..c25efeb6 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -61,13 +61,17 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Nullable private final ResolveInfo mBackupResolveInfo; private final Intent mResolvedIntent; - private final ChooserTarget mChooserTarget; private final String mDisplayLabel; @Nullable private final AppTarget mAppTarget; @Nullable private final ShortcutInfo mShortcutInfo; + private final ComponentName mChooserTargetComponentName; + private final String mChooserTargetUnsanitizedTitle; + private final Icon mChooserTargetIcon; + private final Bundle mChooserTargetIntentExtras; + /** * A refinement intent from the caller, if any (see * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER}) @@ -116,7 +120,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Nullable AppTarget appTarget, Intent referrerFillInIntent) { mSourceInfo = sourceInfo; - mChooserTarget = chooserTarget; mModifiedScore = modifiedScore; mShortcutInfo = shortcutInfo; mAppTarget = appTarget; @@ -128,14 +131,18 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mFillInIntent = null; mFillInFlags = 0; - mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle()); + mChooserTargetComponentName = chooserTarget.getComponentName(); + mChooserTargetUnsanitizedTitle = chooserTarget.getTitle().toString(); + mChooserTargetIcon = chooserTarget.getIcon(); + mChooserTargetIntentExtras = chooserTarget.getIntentExtras(); + + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); } private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { mSourceInfo = other.mSourceInfo; mBackupResolveInfo = other.mBackupResolveInfo; mResolvedIntent = other.mResolvedIntent; - mChooserTarget = other.mChooserTarget; mShortcutInfo = other.mShortcutInfo; mAppTarget = other.mAppTarget; mDisplayIcon = other.mDisplayIcon; @@ -145,7 +152,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mIsPinned = other.mIsPinned; mReferrerFillInIntent = other.mReferrerFillInIntent; - mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); + mChooserTargetComponentName = other.mChooserTargetComponentName; + mChooserTargetUnsanitizedTitle = other.mChooserTargetUnsanitizedTitle; + mChooserTargetIcon = other.mChooserTargetIcon; + mChooserTargetIntentExtras = other.mChooserTargetIntentExtras; + + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); } @Override @@ -187,12 +199,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public ComponentName getChooserTargetComponentName() { - return mChooserTarget.getComponentName(); + return mChooserTargetComponentName; } @Nullable public Icon getChooserTargetIcon() { - return mChooserTarget.getIcon(); + return mChooserTargetIcon; } private Intent getBaseIntentToSend() { @@ -220,8 +232,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { if (intent == null) { return false; } - intent.setComponent(mChooserTarget.getComponentName()); - intent.putExtras(mChooserTarget.getIntentExtras()); + intent.setComponent(getChooserTargetComponentName()); + intent.putExtras(mChooserTargetIntentExtras); TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); // Important: we will ignore the target security checks in ActivityManager @@ -234,7 +246,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { // so we'll obey the caller's normal security checks. final boolean ignoreTargetSecurity = mSourceInfo != null && mSourceInfo.getResolvedComponentName().getPackageName() - .equals(mChooserTarget.getComponentName().getPackageName()); + .equals(getChooserTargetComponentName().getPackageName()); activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); return true; } @@ -304,8 +316,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { final String plaintext = - mChooserTarget.getComponentName().getPackageName() - + mChooserTarget.getTitle().toString(); + getChooserTargetComponentName().getPackageName() + + mChooserTargetUnsanitizedTitle; return HashedStringCache.getInstance().hashString( context, HASHED_STRING_CACHE_TAG, -- cgit v1.2.3-59-g8ed1b From dcb7c99e338b28d9c1d820d9f4f459518d1cbc35 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 25 Oct 2022 15:44:34 -0400 Subject: Migrate ranking components to subpackage. I still have some old refactoring CLs outstanding around these components, but for now I just want to group them together (and start emptying out some of our top-level source directory). Long-term this subpackage probably merges with some or all of the responsibilities in the `shortcuts` subpackage (if we imagine that "sourcing" targets and "scoring/ranking" them are both responsibilities of some backend "data model"). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: If15bf9b8ac1989bd3ded65e2c4bfa0bc2abc171e --- .../intentresolver/AbstractResolverComparator.java | 291 ---------- .../AppPredictionServiceResolverComparator.java | 276 ---------- .../android/intentresolver/ChooserActivity.java | 3 + .../android/intentresolver/ResolverActivity.java | 5 +- .../intentresolver/ResolverComparatorModel.java | 57 -- .../intentresolver/ResolverListController.java | 18 +- .../ResolverRankerServiceResolverComparator.java | 599 -------------------- .../model/AbstractResolverComparator.java | 285 ++++++++++ .../AppPredictionServiceResolverComparator.java | 277 ++++++++++ .../model/ResolverComparatorModel.java | 56 ++ .../ResolverRankerServiceResolverComparator.java | 601 +++++++++++++++++++++ .../AbstractResolverComparatorTest.java | 105 ---- .../model/AbstractResolverComparatorTest.java | 107 ++++ 13 files changed, 1335 insertions(+), 1345 deletions(-) delete mode 100644 java/src/com/android/intentresolver/AbstractResolverComparator.java delete mode 100644 java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java delete mode 100644 java/src/com/android/intentresolver/ResolverComparatorModel.java delete mode 100644 java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java create mode 100644 java/src/com/android/intentresolver/model/AbstractResolverComparator.java create mode 100644 java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java create mode 100644 java/src/com/android/intentresolver/model/ResolverComparatorModel.java create mode 100644 java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java delete mode 100644 java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java create mode 100644 java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractResolverComparator.java b/java/src/com/android/intentresolver/AbstractResolverComparator.java deleted file mode 100644 index 07dcd664..00000000 --- a/java/src/com/android/intentresolver/AbstractResolverComparator.java +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright 2018 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.intentresolver; - -import android.app.usage.UsageStatsManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.os.BadParcelableException; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.os.UserHandle; -import android.util.Log; - -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; - -import java.text.Collator; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -/** - * Used to sort resolved activities in {@link ResolverListController}. - * - * @hide - */ -public abstract class AbstractResolverComparator implements Comparator { - - private static final int NUM_OF_TOP_ANNOTATIONS_TO_USE = 3; - private static final boolean DEBUG = true; - private static final String TAG = "AbstractResolverComp"; - - protected AfterCompute mAfterCompute; - protected final PackageManager mPm; - protected final UsageStatsManager mUsm; - protected String[] mAnnotations; - protected String mContentType; - - // True if the current share is a link. - private final boolean mHttp; - - // message types - static final int RANKER_SERVICE_RESULT = 0; - static final int RANKER_RESULT_TIMEOUT = 1; - - // timeout for establishing connections with a ResolverRankerService, collecting features and - // predicting ranking scores. - private static final int WATCHDOG_TIMEOUT_MILLIS = 500; - - private final Comparator mAzComparator; - private ChooserActivityLogger mChooserActivityLogger; - - protected final Handler mHandler = new Handler(Looper.getMainLooper()) { - public void handleMessage(Message msg) { - switch (msg.what) { - case RANKER_SERVICE_RESULT: - if (DEBUG) { - Log.d(TAG, "RANKER_SERVICE_RESULT"); - } - if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { - handleResultMessage(msg); - mHandler.removeMessages(RANKER_RESULT_TIMEOUT); - afterCompute(); - } - break; - - case RANKER_RESULT_TIMEOUT: - if (DEBUG) { - Log.d(TAG, "RANKER_RESULT_TIMEOUT; unbinding services"); - } - mHandler.removeMessages(RANKER_SERVICE_RESULT); - afterCompute(); - if (mChooserActivityLogger != null) { - mChooserActivityLogger.logSharesheetAppShareRankingTimeout(); - } - break; - - default: - super.handleMessage(msg); - } - } - }; - - public AbstractResolverComparator(Context context, Intent intent) { - String scheme = intent.getScheme(); - mHttp = "http".equals(scheme) || "https".equals(scheme); - mContentType = intent.getType(); - getContentAnnotations(intent); - mPm = context.getPackageManager(); - mUsm = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); - mAzComparator = new AzInfoComparator(context); - } - - // get annotations of content from intent. - private void getContentAnnotations(Intent intent) { - try { - ArrayList annotations = intent.getStringArrayListExtra( - Intent.EXTRA_CONTENT_ANNOTATIONS); - if (annotations != null) { - int size = annotations.size(); - if (size > NUM_OF_TOP_ANNOTATIONS_TO_USE) { - size = NUM_OF_TOP_ANNOTATIONS_TO_USE; - } - mAnnotations = new String[size]; - for (int i = 0; i < size; i++) { - mAnnotations[i] = annotations.get(i); - } - } - } catch (BadParcelableException e) { - Log.i(TAG, "Couldn't unparcel intent annotations. Ignoring."); - mAnnotations = new String[0]; - } - } - - /** - * Callback to be called when {@link #compute(List)} finishes. This signals to stop waiting. - */ - interface AfterCompute { - - void afterCompute(); - } - - void setCallBack(AfterCompute afterCompute) { - mAfterCompute = afterCompute; - } - - void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) { - mChooserActivityLogger = chooserActivityLogger; - } - - ChooserActivityLogger getChooserActivityLogger() { - return mChooserActivityLogger; - } - - protected final void afterCompute() { - final AfterCompute afterCompute = mAfterCompute; - if (afterCompute != null) { - afterCompute.afterCompute(); - } - } - - @Override - public final int compare(ResolvedComponentInfo lhsp, ResolvedComponentInfo rhsp) { - final ResolveInfo lhs = lhsp.getResolveInfoAt(0); - final ResolveInfo rhs = rhsp.getResolveInfoAt(0); - - // We want to put the one targeted to another user at the end of the dialog. - if (lhs.targetUserId != UserHandle.USER_CURRENT) { - return rhs.targetUserId != UserHandle.USER_CURRENT ? 0 : 1; - } - if (rhs.targetUserId != UserHandle.USER_CURRENT) { - return -1; - } - - if (mHttp) { - final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match); - final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match); - if (lhsSpecific != rhsSpecific) { - return lhsSpecific ? -1 : 1; - } - } - - final boolean lPinned = lhsp.isPinned(); - final boolean rPinned = rhsp.isPinned(); - - // Pinned items always receive priority. - if (lPinned && !rPinned) { - return -1; - } else if (!lPinned && rPinned) { - return 1; - } else if (lPinned && rPinned) { - // If both items are pinned, resolve the tie alphabetically. - return mAzComparator.compare(lhsp.getResolveInfoAt(0), rhsp.getResolveInfoAt(0)); - } - - return compare(lhs, rhs); - } - - /** - * Delegated to when used as a {@link Comparator} if there is not a - * special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in - * {@link ResolvedComponentInfo#getResolveInfoAt(int)} from the parameters of {@link - * #compare(ResolvedComponentInfo, ResolvedComponentInfo)} - */ - abstract int compare(ResolveInfo lhs, ResolveInfo rhs); - - /** - * Computes features for each target. This will be called before calls to {@link - * #getScore(ComponentName)} or {@link #compare(Object, Object)}, in order to prepare the - * comparator for those calls. Note that {@link #getScore(ComponentName)} uses {@link - * ComponentName}, so the implementation will have to be prepared to identify a {@link - * ResolvedComponentInfo} by {@link ComponentName}. {@link #beforeCompute()} will be called - * before doing any computing. - */ - final void compute(List targets) { - beforeCompute(); - doCompute(targets); - } - - /** Implementation of compute called after {@link #beforeCompute()}. */ - abstract void doCompute(List targets); - - /** - * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} - * when {@link #compute(List)} was called before this. - */ - abstract float getScore(ComponentName name); - - /** Handles result message sent to mHandler. */ - abstract void handleResultMessage(Message message); - - /** - * Reports to UsageStats what was chosen. - */ - final void updateChooserCounts(String packageName, int userId, String action) { - if (mUsm != null) { - mUsm.reportChooserSelection(packageName, userId, mContentType, mAnnotations, action); - } - } - - /** - * Updates the model used to rank the componentNames. - * - *

    Default implementation does nothing, as we could have simple model that does not train - * online. - * - * @param componentName the component that the user clicked - */ - void updateModel(ComponentName componentName) { - } - - /** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */ - void beforeCompute() { - if (DEBUG) Log.d(TAG, "Setting watchdog timer for " + WATCHDOG_TIMEOUT_MILLIS + "ms"); - if (mHandler == null) { - Log.d(TAG, "Error: Handler is Null; Needs to be initialized."); - return; - } - mHandler.sendEmptyMessageDelayed(RANKER_RESULT_TIMEOUT, WATCHDOG_TIMEOUT_MILLIS); - } - - /** - * Called when the {@link ResolverActivity} is destroyed. This calls {@link #afterCompute()}. If - * this call needs to happen at a different time during destroy, the method should be - * overridden. - */ - void destroy() { - mHandler.removeMessages(RANKER_SERVICE_RESULT); - mHandler.removeMessages(RANKER_RESULT_TIMEOUT); - afterCompute(); - mAfterCompute = null; - } - - /** - * Sort intents alphabetically based on package name. - */ - class AzInfoComparator implements Comparator { - Collator mCollator; - AzInfoComparator(Context context) { - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); - } - - @Override - public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { - if (lhsp == null) { - return -1; - } else if (rhsp == null) { - return 1; - } - return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); - } - } - -} diff --git a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java deleted file mode 100644 index 9b9fc1c0..00000000 --- a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2018 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.intentresolver; - -import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH; - -import android.annotation.Nullable; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.app.prediction.AppTargetEvent; -import android.app.prediction.AppTargetId; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.os.Message; -import android.os.UserHandle; -import android.util.Log; - -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executors; - -/** - * Uses an {@link AppPredictor} to sort Resolver targets. If the AppPredictionService appears to be - * disabled by returning an empty sorted target list, {@link AppPredictionServiceResolverComparator} - * will fallback to using a {@link ResolverRankerServiceResolverComparator}. - */ -class AppPredictionServiceResolverComparator extends AbstractResolverComparator { - - private static final String TAG = "APSResolverComparator"; - - private final AppPredictor mAppPredictor; - private final Context mContext; - private final Map mTargetRanks = new HashMap<>(); - private final Map mTargetScores = new HashMap<>(); - private final UserHandle mUser; - private final Intent mIntent; - private final String mReferrerPackage; - // If this is non-null (and this is not destroyed), it means APS is disabled and we should fall - // back to using the ResolverRankerService. - // TODO: responsibility for this fallback behavior can live outside of the AppPrediction client. - private ResolverRankerServiceResolverComparator mResolverRankerService; - private AppPredictionServiceComparatorModel mComparatorModel; - - AppPredictionServiceResolverComparator( - Context context, - Intent intent, - String referrerPackage, - AppPredictor appPredictor, - UserHandle user, - ChooserActivityLogger chooserActivityLogger) { - super(context, intent); - mContext = context; - mIntent = intent; - mAppPredictor = appPredictor; - mUser = user; - mReferrerPackage = referrerPackage; - setChooserActivityLogger(chooserActivityLogger); - mComparatorModel = buildUpdatedModel(); - } - - @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { - return mComparatorModel.getComparator().compare(lhs, rhs); - } - - @Override - void doCompute(List targets) { - if (targets.isEmpty()) { - mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); - return; - } - List appTargets = new ArrayList<>(); - for (ResolvedComponentInfo target : targets) { - appTargets.add( - new AppTarget.Builder( - new AppTargetId(target.name.flattenToString()), - target.name.getPackageName(), - mUser) - .setClassName(target.name.getClassName()) - .build()); - } - mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(), - sortedAppTargets -> { - if (sortedAppTargets.isEmpty()) { - Log.i(TAG, "AppPredictionService disabled. Using resolver."); - // APS for chooser is disabled. Fallback to resolver. - mResolverRankerService = - new ResolverRankerServiceResolverComparator( - mContext, mIntent, mReferrerPackage, - () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getChooserActivityLogger()); - mComparatorModel = buildUpdatedModel(); - mResolverRankerService.compute(targets); - } else { - Log.i(TAG, "AppPredictionService response received"); - // Skip sending to Handler which takes extra time to dispatch messages. - handleResult(sortedAppTargets); - } - } - ); - } - - @Override - void handleResultMessage(Message msg) { - // Null value is okay if we have defaulted to the ResolverRankerService. - if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) { - final List sortedAppTargets = (List) msg.obj; - handleSortedAppTargets(sortedAppTargets); - } else if (msg.obj == null && mResolverRankerService == null) { - Log.e(TAG, "Unexpected null result"); - } - } - - private void handleResult(List sortedAppTargets) { - if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { - handleSortedAppTargets(sortedAppTargets); - mHandler.removeMessages(RANKER_RESULT_TIMEOUT); - afterCompute(); - } - } - - private void handleSortedAppTargets(List sortedAppTargets) { - if (checkAppTargetRankValid(sortedAppTargets)) { - sortedAppTargets.forEach(target -> mTargetScores.put( - new ComponentName(target.getPackageName(), target.getClassName()), - target.getRank())); - } - for (int i = 0; i < sortedAppTargets.size(); i++) { - ComponentName componentName = new ComponentName( - sortedAppTargets.get(i).getPackageName(), - sortedAppTargets.get(i).getClassName()); - mTargetRanks.put(componentName, i); - Log.i(TAG, "handleSortedAppTargets, sortedAppTargets #" + i + ": " + componentName); - } - mComparatorModel = buildUpdatedModel(); - } - - private boolean checkAppTargetRankValid(List sortedAppTargets) { - for (AppTarget target : sortedAppTargets) { - if (target.getRank() != 0) { - return true; - } - } - return false; - } - - @Override - float getScore(ComponentName name) { - return mComparatorModel.getScore(name); - } - - @Override - void updateModel(ComponentName componentName) { - mComparatorModel.notifyOnTargetSelected(componentName); - } - - @Override - void destroy() { - if (mResolverRankerService != null) { - mResolverRankerService.destroy(); - mResolverRankerService = null; - mComparatorModel = buildUpdatedModel(); - } - } - - /** - * Re-construct an {@code AppPredictionServiceComparatorModel} to replace the current model - * instance (if any) using the up-to-date {@code AppPredictionServiceResolverComparator} ivar - * values. - * - * TODO: each time we replace the model instance, we're either updating the model to use - * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars - * that wasn't available the last time the model was updated. For those latter cases, we should - * just avoid creating the model altogether until we have all the prerequisites we'll need. Then - * we can probably simplify the logic in {@code AppPredictionServiceComparatorModel} since we - * won't need to handle edge cases when the model data isn't fully prepared. - * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished - * initializing the first time and now want to adjust some data, but still need to wait for - * changes to propagate to the other ivars before rebuilding the model.) - */ - private AppPredictionServiceComparatorModel buildUpdatedModel() { - return new AppPredictionServiceComparatorModel( - mAppPredictor, mResolverRankerService, mUser, mTargetRanks); - } - - // TODO: Finish separating behaviors of AbstractResolverComparator, then (probably) make this a - // standalone class once clients are written in terms of ResolverComparatorModel. - static class AppPredictionServiceComparatorModel implements ResolverComparatorModel { - private final AppPredictor mAppPredictor; - private final ResolverRankerServiceResolverComparator mResolverRankerService; - private final UserHandle mUser; - private final Map mTargetRanks; // Treat as immutable. - - AppPredictionServiceComparatorModel( - AppPredictor appPredictor, - @Nullable ResolverRankerServiceResolverComparator resolverRankerService, - UserHandle user, - Map targetRanks) { - mAppPredictor = appPredictor; - mResolverRankerService = resolverRankerService; - mUser = user; - mTargetRanks = targetRanks; - } - - @Override - public Comparator getComparator() { - return (lhs, rhs) -> { - if (mResolverRankerService != null) { - return mResolverRankerService.compare(lhs, rhs); - } - Integer lhsRank = mTargetRanks.get(new ComponentName(lhs.activityInfo.packageName, - lhs.activityInfo.name)); - Integer rhsRank = mTargetRanks.get(new ComponentName(rhs.activityInfo.packageName, - rhs.activityInfo.name)); - if (lhsRank == null && rhsRank == null) { - return 0; - } else if (lhsRank == null) { - return -1; - } else if (rhsRank == null) { - return 1; - } - return lhsRank - rhsRank; - }; - } - - @Override - public float getScore(ComponentName name) { - if (mResolverRankerService != null) { - return mResolverRankerService.getScore(name); - } - Integer rank = mTargetRanks.get(name); - if (rank == null) { - Log.w(TAG, "Score requested for unknown component. Did you call compute yet?"); - return 0f; - } - int consecutiveSumOfRanks = (mTargetRanks.size() - 1) * (mTargetRanks.size()) / 2; - return 1.0f - (((float) rank) / consecutiveSumOfRanks); - } - - @Override - public void notifyOnTargetSelected(ComponentName componentName) { - if (mResolverRankerService != null) { - mResolverRankerService.updateModel(componentName); - return; - } - mAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder( - new AppTarget.Builder( - new AppTargetId(componentName.toString()), - componentName.getPackageName(), mUser) - .setClassName(componentName.getClassName()).build(), - ACTION_LAUNCH).build()); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 6735ab4e..a72425a0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -121,6 +121,9 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.model.AbstractResolverComparator; +import com.android.intentresolver.model.AppPredictionServiceResolverComparator; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 19251490..46a41b50 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -2244,8 +2244,9 @@ public class ResolverActivity extends FragmentActivity implements } - static final boolean isSpecificUriMatch(int match) { - match = match&IntentFilter.MATCH_CATEGORY_MASK; + /** Determine whether a given match result is considered "specific" in our application. */ + public static final boolean isSpecificUriMatch(int match) { + match = (match & IntentFilter.MATCH_CATEGORY_MASK); return match >= IntentFilter.MATCH_CATEGORY_HOST && match <= IntentFilter.MATCH_CATEGORY_PATH; } diff --git a/java/src/com/android/intentresolver/ResolverComparatorModel.java b/java/src/com/android/intentresolver/ResolverComparatorModel.java deleted file mode 100644 index 79160c84..00000000 --- a/java/src/com/android/intentresolver/ResolverComparatorModel.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2022 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.intentresolver; - -import android.content.ComponentName; -import android.content.pm.ResolveInfo; - -import java.util.Comparator; -import java.util.List; - -/** - * A ranking model for resolver targets, providing ordering and (optionally) numerical scoring. - * - * As required by the {@link Comparator} contract, objects returned by {@code getComparator()} must - * apply a total ordering on its inputs consistent across all calls to {@code Comparator#compare()}. - * Other query methods and ranking feedback should refer to that same ordering, so implementors are - * generally advised to "lock in" an immutable snapshot of their model data when this object is - * initialized (preferring to replace the entire {@code ResolverComparatorModel} instance if the - * backing data needs to be updated in the future). - */ -interface ResolverComparatorModel { - /** - * Get a {@code Comparator} that can be used to sort {@code ResolveInfo} targets according to - * the model ranking. - */ - Comparator getComparator(); - - /** - * Get the numerical score, if any, that the model assigns to the component with the specified - * {@code name}. Scores range from zero to one, with one representing the highest possible - * likelihood that the user will select that component as the target. Implementations that don't - * assign numerical scores are recommended to return a value of 0 for all components. - */ - float getScore(ComponentName name); - - /** - * Notify the model that the user selected a target. (Models may log this information, use it as - * a feedback signal for their ranking, etc.) Because the data in this - * {@code ResolverComparatorModel} instance is immutable, clients will need to get an up-to-date - * instance in order to see any changes in the ranking that might result from this feedback. - */ - void notifyOnTargetSelected(ComponentName componentName); -} diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index 6169c032..bfffe0d8 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -32,6 +32,8 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.model.AbstractResolverComparator; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; @@ -264,19 +266,6 @@ public class ResolverListController { return listToReturn; } - private class ComputeCallback implements AbstractResolverComparator.AfterCompute { - - private CountDownLatch mFinishComputeSignal; - - public ComputeCallback(CountDownLatch finishComputeSignal) { - mFinishComputeSignal = finishComputeSignal; - } - - public void afterCompute () { - mFinishComputeSignal.countDown(); - } - } - private void compute(List inputList) throws InterruptedException { if (mResolverComparator == null) { @@ -284,8 +273,7 @@ public class ResolverListController { return; } final CountDownLatch finishComputeSignal = new CountDownLatch(1); - ComputeCallback callback = new ComputeCallback(finishComputeSignal); - mResolverComparator.setCallBack(callback); + mResolverComparator.setCallBack(() -> finishComputeSignal.countDown()); mResolverComparator.compute(inputList); finishComputeSignal.await(); isComputed = true; diff --git a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java deleted file mode 100644 index be3e6f18..00000000 --- a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java +++ /dev/null @@ -1,599 +0,0 @@ -/* - * Copyright (C) 2015 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.intentresolver; - -import android.app.usage.UsageStats; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ResolveInfo; -import android.metrics.LogMaker; -import android.os.IBinder; -import android.os.Message; -import android.os.RemoteException; -import android.os.UserHandle; -import android.service.resolver.IResolverRankerResult; -import android.service.resolver.IResolverRankerService; -import android.service.resolver.ResolverRankerService; -import android.service.resolver.ResolverTarget; -import android.util.Log; - -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; - -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; - -import java.text.Collator; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * Ranks and compares packages based on usage stats and uses the {@link ResolverRankerService}. - */ -class ResolverRankerServiceResolverComparator extends AbstractResolverComparator { - private static final String TAG = "RRSResolverComparator"; - - private static final boolean DEBUG = false; - - // One week - private static final long USAGE_STATS_PERIOD = 1000 * 60 * 60 * 24 * 7; - - private static final long RECENCY_TIME_PERIOD = 1000 * 60 * 60 * 12; - - private static final float RECENCY_MULTIPLIER = 2.f; - - // timeout for establishing connections with a ResolverRankerService. - private static final int CONNECTION_COST_TIMEOUT_MILLIS = 200; - - private final Collator mCollator; - private final Map mStats; - private final long mCurrentTime; - private final long mSinceTime; - private final LinkedHashMap mTargetsDict = new LinkedHashMap<>(); - private final String mReferrerPackage; - private final Object mLock = new Object(); - private ArrayList mTargets; - private String mAction; - private ComponentName mResolvedRankerName; - private ComponentName mRankerServiceName; - private IResolverRankerService mRanker; - private ResolverRankerServiceConnection mConnection; - private Context mContext; - private CountDownLatch mConnectSignal; - private ResolverRankerServiceComparatorModel mComparatorModel; - - public ResolverRankerServiceResolverComparator(Context context, Intent intent, - String referrerPackage, AfterCompute afterCompute, - ChooserActivityLogger chooserActivityLogger) { - super(context, intent); - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); - mReferrerPackage = referrerPackage; - mContext = context; - - mCurrentTime = System.currentTimeMillis(); - mSinceTime = mCurrentTime - USAGE_STATS_PERIOD; - mStats = mUsm.queryAndAggregateUsageStats(mSinceTime, mCurrentTime); - mAction = intent.getAction(); - mRankerServiceName = new ComponentName(mContext, this.getClass()); - setCallBack(afterCompute); - setChooserActivityLogger(chooserActivityLogger); - - mComparatorModel = buildUpdatedModel(); - } - - @Override - public void handleResultMessage(Message msg) { - if (msg.what != RANKER_SERVICE_RESULT) { - return; - } - if (msg.obj == null) { - Log.e(TAG, "Receiving null prediction results."); - return; - } - final List receivedTargets = (List) msg.obj; - if (receivedTargets != null && mTargets != null - && receivedTargets.size() == mTargets.size()) { - final int size = mTargets.size(); - boolean isUpdated = false; - for (int i = 0; i < size; ++i) { - final float predictedProb = - receivedTargets.get(i).getSelectProbability(); - if (predictedProb != mTargets.get(i).getSelectProbability()) { - mTargets.get(i).setSelectProbability(predictedProb); - isUpdated = true; - } - } - if (isUpdated) { - mRankerServiceName = mResolvedRankerName; - mComparatorModel = buildUpdatedModel(); - } - } else { - Log.e(TAG, "Sizes of sent and received ResolverTargets diff."); - } - } - - // compute features for each target according to usage stats of targets. - @Override - public void doCompute(List targets) { - final long recentSinceTime = mCurrentTime - RECENCY_TIME_PERIOD; - - float mostRecencyScore = 1.0f; - float mostTimeSpentScore = 1.0f; - float mostLaunchScore = 1.0f; - float mostChooserScore = 1.0f; - - for (ResolvedComponentInfo target : targets) { - final ResolverTarget resolverTarget = new ResolverTarget(); - mTargetsDict.put(target.name, resolverTarget); - final UsageStats pkStats = mStats.get(target.name.getPackageName()); - if (pkStats != null) { - // Only count recency for apps that weren't the caller - // since the caller is always the most recent. - // Persistent processes muck this up, so omit them too. - if (!target.name.getPackageName().equals(mReferrerPackage) - && !isPersistentProcess(target)) { - final float recencyScore = - (float) Math.max(pkStats.getLastTimeUsed() - recentSinceTime, 0); - resolverTarget.setRecencyScore(recencyScore); - if (recencyScore > mostRecencyScore) { - mostRecencyScore = recencyScore; - } - } - final float timeSpentScore = (float) pkStats.getTotalTimeInForeground(); - resolverTarget.setTimeSpentScore(timeSpentScore); - if (timeSpentScore > mostTimeSpentScore) { - mostTimeSpentScore = timeSpentScore; - } - final float launchScore = (float) pkStats.mLaunchCount; - resolverTarget.setLaunchScore(launchScore); - if (launchScore > mostLaunchScore) { - mostLaunchScore = launchScore; - } - - float chooserScore = 0.0f; - if (pkStats.mChooserCounts != null && mAction != null - && pkStats.mChooserCounts.get(mAction) != null) { - chooserScore = (float) pkStats.mChooserCounts.get(mAction) - .getOrDefault(mContentType, 0); - if (mAnnotations != null) { - final int size = mAnnotations.length; - for (int i = 0; i < size; i++) { - chooserScore += (float) pkStats.mChooserCounts.get(mAction) - .getOrDefault(mAnnotations[i], 0); - } - } - } - if (DEBUG) { - if (mAction == null) { - Log.d(TAG, "Action type is null"); - } else { - Log.d(TAG, "Chooser Count of " + mAction + ":" + - target.name.getPackageName() + " is " + - Float.toString(chooserScore)); - } - } - resolverTarget.setChooserScore(chooserScore); - if (chooserScore > mostChooserScore) { - mostChooserScore = chooserScore; - } - } - } - - if (DEBUG) { - Log.d(TAG, "compute - mostRecencyScore: " + mostRecencyScore - + " mostTimeSpentScore: " + mostTimeSpentScore - + " mostLaunchScore: " + mostLaunchScore - + " mostChooserScore: " + mostChooserScore); - } - - mTargets = new ArrayList<>(mTargetsDict.values()); - for (ResolverTarget target : mTargets) { - final float recency = target.getRecencyScore() / mostRecencyScore; - setFeatures(target, recency * recency * RECENCY_MULTIPLIER, - target.getLaunchScore() / mostLaunchScore, - target.getTimeSpentScore() / mostTimeSpentScore, - target.getChooserScore() / mostChooserScore); - addDefaultSelectProbability(target); - if (DEBUG) { - Log.d(TAG, "Scores: " + target); - } - } - predictSelectProbabilities(mTargets); - - mComparatorModel = buildUpdatedModel(); - } - - @Override - public int compare(ResolveInfo lhs, ResolveInfo rhs) { - return mComparatorModel.getComparator().compare(lhs, rhs); - } - - @Override - public float getScore(ComponentName name) { - return mComparatorModel.getScore(name); - } - - // update ranking model when the connection to it is valid. - @Override - public void updateModel(ComponentName componentName) { - synchronized (mLock) { - mComparatorModel.notifyOnTargetSelected(componentName); - } - } - - // unbind the service and clear unhandled messges. - @Override - public void destroy() { - mHandler.removeMessages(RANKER_SERVICE_RESULT); - mHandler.removeMessages(RANKER_RESULT_TIMEOUT); - if (mConnection != null) { - mContext.unbindService(mConnection); - mConnection.destroy(); - } - afterCompute(); - if (DEBUG) { - Log.d(TAG, "Unbinded Resolver Ranker."); - } - } - - // connect to a ranking service. - private void initRanker(Context context) { - synchronized (mLock) { - if (mConnection != null && mRanker != null) { - if (DEBUG) { - Log.d(TAG, "Ranker still exists; reusing the existing one."); - } - return; - } - } - Intent intent = resolveRankerService(); - if (intent == null) { - return; - } - mConnectSignal = new CountDownLatch(1); - mConnection = new ResolverRankerServiceConnection(mConnectSignal); - context.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE, UserHandle.SYSTEM); - } - - // resolve the service for ranking. - private Intent resolveRankerService() { - Intent intent = new Intent(ResolverRankerService.SERVICE_INTERFACE); - final List resolveInfos = mPm.queryIntentServices(intent, 0); - for (ResolveInfo resolveInfo : resolveInfos) { - if (resolveInfo == null || resolveInfo.serviceInfo == null - || resolveInfo.serviceInfo.applicationInfo == null) { - if (DEBUG) { - Log.d(TAG, "Failed to retrieve a ranker: " + resolveInfo); - } - continue; - } - ComponentName componentName = new ComponentName( - resolveInfo.serviceInfo.applicationInfo.packageName, - resolveInfo.serviceInfo.name); - try { - final String perm = mPm.getServiceInfo(componentName, 0).permission; - if (!ResolverRankerService.BIND_PERMISSION.equals(perm)) { - Log.w(TAG, "ResolverRankerService " + componentName + " does not require" - + " permission " + ResolverRankerService.BIND_PERMISSION - + " - this service will not be queried for " - + "ResolverRankerServiceResolverComparator. add android:permission=\"" - + ResolverRankerService.BIND_PERMISSION + "\"" - + " to the tag for " + componentName - + " in the manifest."); - continue; - } - if (PackageManager.PERMISSION_GRANTED != mPm.checkPermission( - ResolverRankerService.HOLD_PERMISSION, - resolveInfo.serviceInfo.packageName)) { - Log.w(TAG, "ResolverRankerService " + componentName + " does not hold" - + " permission " + ResolverRankerService.HOLD_PERMISSION - + " - this service will not be queried for " - + "ResolverRankerServiceResolverComparator."); - continue; - } - } catch (NameNotFoundException e) { - Log.e(TAG, "Could not look up service " + componentName - + "; component name not found"); - continue; - } - if (DEBUG) { - Log.d(TAG, "Succeeded to retrieve a ranker: " + componentName); - } - mResolvedRankerName = componentName; - intent.setComponent(componentName); - return intent; - } - return null; - } - - private class ResolverRankerServiceConnection implements ServiceConnection { - private final CountDownLatch mConnectSignal; - - public ResolverRankerServiceConnection(CountDownLatch connectSignal) { - mConnectSignal = connectSignal; - } - - public final IResolverRankerResult resolverRankerResult = - new IResolverRankerResult.Stub() { - @Override - public void sendResult(List targets) throws RemoteException { - if (DEBUG) { - Log.d(TAG, "Sending Result back to Resolver: " + targets); - } - synchronized (mLock) { - final Message msg = Message.obtain(); - msg.what = RANKER_SERVICE_RESULT; - msg.obj = targets; - mHandler.sendMessage(msg); - } - } - }; - - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - if (DEBUG) { - Log.d(TAG, "onServiceConnected: " + name); - } - synchronized (mLock) { - mRanker = IResolverRankerService.Stub.asInterface(service); - mComparatorModel = buildUpdatedModel(); - mConnectSignal.countDown(); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - if (DEBUG) { - Log.d(TAG, "onServiceDisconnected: " + name); - } - synchronized (mLock) { - destroy(); - } - } - - public void destroy() { - synchronized (mLock) { - mRanker = null; - mComparatorModel = buildUpdatedModel(); - } - } - } - - @Override - void beforeCompute() { - super.beforeCompute(); - mTargetsDict.clear(); - mTargets = null; - mRankerServiceName = new ComponentName(mContext, this.getClass()); - mComparatorModel = buildUpdatedModel(); - mResolvedRankerName = null; - initRanker(mContext); - } - - // predict select probabilities if ranking service is valid. - private void predictSelectProbabilities(List targets) { - if (mConnection == null) { - if (DEBUG) { - Log.d(TAG, "Has not found valid ResolverRankerService; Skip Prediction"); - } - } else { - try { - mConnectSignal.await(CONNECTION_COST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); - synchronized (mLock) { - if (mRanker != null) { - mRanker.predict(targets, mConnection.resolverRankerResult); - return; - } else { - if (DEBUG) { - Log.d(TAG, "Ranker has not been initialized; skip predict."); - } - } - } - } catch (InterruptedException e) { - Log.e(TAG, "Error in Wait for Service Connection."); - } catch (RemoteException e) { - Log.e(TAG, "Error in Predict: " + e); - } - } - afterCompute(); - } - - // adds select prob as the default values, according to a pre-trained Logistic Regression model. - private void addDefaultSelectProbability(ResolverTarget target) { - float sum = 2.5543f * target.getLaunchScore() + 2.8412f * target.getTimeSpentScore() + - 0.269f * target.getRecencyScore() + 4.2222f * target.getChooserScore(); - target.setSelectProbability((float) (1.0 / (1.0 + Math.exp(1.6568f - sum)))); - } - - // sets features for each target - private void setFeatures(ResolverTarget target, float recencyScore, float launchScore, - float timeSpentScore, float chooserScore) { - target.setRecencyScore(recencyScore); - target.setLaunchScore(launchScore); - target.setTimeSpentScore(timeSpentScore); - target.setChooserScore(chooserScore); - } - - static boolean isPersistentProcess(ResolvedComponentInfo rci) { - if (rci != null && rci.getCount() > 0) { - return (rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags & - ApplicationInfo.FLAG_PERSISTENT) != 0; - } - return false; - } - - /** - * Re-construct a {@code ResolverRankerServiceComparatorModel} to replace the current model - * instance (if any) using the up-to-date {@code ResolverRankerServiceResolverComparator} ivar - * values. - * - * TODO: each time we replace the model instance, we're either updating the model to use - * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars - * that wasn't available the last time the model was updated. For those latter cases, we should - * just avoid creating the model altogether until we have all the prerequisites we'll need. Then - * we can probably simplify the logic in {@code ResolverRankerServiceComparatorModel} since we - * won't need to handle edge cases when the model data isn't fully prepared. - * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished - * initializing the first time and now want to adjust some data, but still need to wait for - * changes to propagate to the other ivars before rebuilding the model.) - */ - private ResolverRankerServiceComparatorModel buildUpdatedModel() { - // TODO: we don't currently guarantee that the underlying target list/map won't be mutated, - // so the ResolverComparatorModel may provide inconsistent results. We should make immutable - // copies of the data (waiting for any necessary remaining data before creating the model). - return new ResolverRankerServiceComparatorModel( - mStats, - mTargetsDict, - mTargets, - mCollator, - mRanker, - mRankerServiceName, - (mAnnotations != null), - mPm); - } - - /** - * Implementation of a {@code ResolverComparatorModel} that provides the same ranking logic as - * the legacy {@code ResolverRankerServiceResolverComparator}, as a refactoring step toward - * removing the complex legacy API. - */ - static class ResolverRankerServiceComparatorModel implements ResolverComparatorModel { - private final Map mStats; // Treat as immutable. - private final Map mTargetsDict; // Treat as immutable. - private final List mTargets; // Treat as immutable. - private final Collator mCollator; - private final IResolverRankerService mRanker; - private final ComponentName mRankerServiceName; - private final boolean mAnnotationsUsed; - private final PackageManager mPm; - - // TODO: it doesn't look like we should have to pass both targets and targetsDict, but it's - // not written in a way that makes it clear whether we can derive one from the other (at - // least in this constructor). - ResolverRankerServiceComparatorModel( - Map stats, - Map targetsDict, - List targets, - Collator collator, - IResolverRankerService ranker, - ComponentName rankerServiceName, - boolean annotationsUsed, - PackageManager pm) { - mStats = stats; - mTargetsDict = targetsDict; - mTargets = targets; - mCollator = collator; - mRanker = ranker; - mRankerServiceName = rankerServiceName; - mAnnotationsUsed = annotationsUsed; - mPm = pm; - } - - @Override - public Comparator getComparator() { - // TODO: doCompute() doesn't seem to be concerned about null-checking mStats. Is that - // a bug there, or do we have a way of knowing it will be non-null under certain - // conditions? - return (lhs, rhs) -> { - if (mStats != null) { - final ResolverTarget lhsTarget = mTargetsDict.get(new ComponentName( - lhs.activityInfo.packageName, lhs.activityInfo.name)); - final ResolverTarget rhsTarget = mTargetsDict.get(new ComponentName( - rhs.activityInfo.packageName, rhs.activityInfo.name)); - - if (lhsTarget != null && rhsTarget != null) { - final int selectProbabilityDiff = Float.compare( - rhsTarget.getSelectProbability(), lhsTarget.getSelectProbability()); - - if (selectProbabilityDiff != 0) { - return selectProbabilityDiff > 0 ? 1 : -1; - } - } - } - - CharSequence sa = lhs.loadLabel(mPm); - if (sa == null) sa = lhs.activityInfo.name; - CharSequence sb = rhs.loadLabel(mPm); - if (sb == null) sb = rhs.activityInfo.name; - - return mCollator.compare(sa.toString().trim(), sb.toString().trim()); - }; - } - - @Override - public float getScore(ComponentName name) { - final ResolverTarget target = mTargetsDict.get(name); - if (target != null) { - return target.getSelectProbability(); - } - return 0; - } - - @Override - public void notifyOnTargetSelected(ComponentName componentName) { - if (mRanker != null) { - try { - int selectedPos = new ArrayList(mTargetsDict.keySet()) - .indexOf(componentName); - if (selectedPos >= 0 && mTargets != null) { - final float selectedProbability = getScore(componentName); - int order = 0; - for (ResolverTarget target : mTargets) { - if (target.getSelectProbability() > selectedProbability) { - order++; - } - } - logMetrics(order); - mRanker.train(mTargets, selectedPos); - } else { - if (DEBUG) { - Log.d(TAG, "Selected a unknown component: " + componentName); - } - } - } catch (RemoteException e) { - Log.e(TAG, "Error in Train: " + e); - } - } else { - if (DEBUG) { - Log.d(TAG, "Ranker is null; skip updateModel."); - } - } - } - - /** Records metrics for evaluation. */ - private void logMetrics(int selectedPos) { - if (mRankerServiceName != null) { - MetricsLogger metricsLogger = new MetricsLogger(); - LogMaker log = new LogMaker(MetricsEvent.ACTION_TARGET_SELECTED); - log.setComponentName(mRankerServiceName); - log.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, mAnnotationsUsed ? 1 : 0); - log.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, selectedPos); - metricsLogger.write(log); - } - } - } -} diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java new file mode 100644 index 00000000..271c6f98 --- /dev/null +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -0,0 +1,285 @@ +/* + * Copyright 2018 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.intentresolver.model; + +import android.app.usage.UsageStatsManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.BadParcelableException; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Used to sort resolved activities in {@link ResolverListController}. + * + * @hide + */ +public abstract class AbstractResolverComparator implements Comparator { + + private static final int NUM_OF_TOP_ANNOTATIONS_TO_USE = 3; + private static final boolean DEBUG = true; + private static final String TAG = "AbstractResolverComp"; + + protected Runnable mAfterCompute; + protected final PackageManager mPm; + protected final UsageStatsManager mUsm; + protected String[] mAnnotations; + protected String mContentType; + + // True if the current share is a link. + private final boolean mHttp; + + // message types + static final int RANKER_SERVICE_RESULT = 0; + static final int RANKER_RESULT_TIMEOUT = 1; + + // timeout for establishing connections with a ResolverRankerService, collecting features and + // predicting ranking scores. + private static final int WATCHDOG_TIMEOUT_MILLIS = 500; + + private final Comparator mAzComparator; + private ChooserActivityLogger mChooserActivityLogger; + + protected final Handler mHandler = new Handler(Looper.getMainLooper()) { + public void handleMessage(Message msg) { + switch (msg.what) { + case RANKER_SERVICE_RESULT: + if (DEBUG) { + Log.d(TAG, "RANKER_SERVICE_RESULT"); + } + if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { + handleResultMessage(msg); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + } + break; + + case RANKER_RESULT_TIMEOUT: + if (DEBUG) { + Log.d(TAG, "RANKER_RESULT_TIMEOUT; unbinding services"); + } + mHandler.removeMessages(RANKER_SERVICE_RESULT); + afterCompute(); + if (mChooserActivityLogger != null) { + mChooserActivityLogger.logSharesheetAppShareRankingTimeout(); + } + break; + + default: + super.handleMessage(msg); + } + } + }; + + public AbstractResolverComparator(Context context, Intent intent) { + String scheme = intent.getScheme(); + mHttp = "http".equals(scheme) || "https".equals(scheme); + mContentType = intent.getType(); + getContentAnnotations(intent); + mPm = context.getPackageManager(); + mUsm = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); + mAzComparator = new AzInfoComparator(context); + } + + // get annotations of content from intent. + private void getContentAnnotations(Intent intent) { + try { + ArrayList annotations = intent.getStringArrayListExtra( + Intent.EXTRA_CONTENT_ANNOTATIONS); + if (annotations != null) { + int size = annotations.size(); + if (size > NUM_OF_TOP_ANNOTATIONS_TO_USE) { + size = NUM_OF_TOP_ANNOTATIONS_TO_USE; + } + mAnnotations = new String[size]; + for (int i = 0; i < size; i++) { + mAnnotations[i] = annotations.get(i); + } + } + } catch (BadParcelableException e) { + Log.i(TAG, "Couldn't unparcel intent annotations. Ignoring."); + mAnnotations = new String[0]; + } + } + + public void setCallBack(Runnable afterCompute) { + mAfterCompute = afterCompute; + } + + void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) { + mChooserActivityLogger = chooserActivityLogger; + } + + ChooserActivityLogger getChooserActivityLogger() { + return mChooserActivityLogger; + } + + protected final void afterCompute() { + final Runnable afterCompute = mAfterCompute; + if (afterCompute != null) { + afterCompute.run(); + } + } + + @Override + public final int compare(ResolvedComponentInfo lhsp, ResolvedComponentInfo rhsp) { + final ResolveInfo lhs = lhsp.getResolveInfoAt(0); + final ResolveInfo rhs = rhsp.getResolveInfoAt(0); + + // We want to put the one targeted to another user at the end of the dialog. + if (lhs.targetUserId != UserHandle.USER_CURRENT) { + return rhs.targetUserId != UserHandle.USER_CURRENT ? 0 : 1; + } + if (rhs.targetUserId != UserHandle.USER_CURRENT) { + return -1; + } + + if (mHttp) { + final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match); + final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match); + if (lhsSpecific != rhsSpecific) { + return lhsSpecific ? -1 : 1; + } + } + + final boolean lPinned = lhsp.isPinned(); + final boolean rPinned = rhsp.isPinned(); + + // Pinned items always receive priority. + if (lPinned && !rPinned) { + return -1; + } else if (!lPinned && rPinned) { + return 1; + } else if (lPinned && rPinned) { + // If both items are pinned, resolve the tie alphabetically. + return mAzComparator.compare(lhsp.getResolveInfoAt(0), rhsp.getResolveInfoAt(0)); + } + + return compare(lhs, rhs); + } + + /** + * Delegated to when used as a {@link Comparator} if there is not a + * special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in + * {@link ResolvedComponentInfo#getResolveInfoAt(int)} from the parameters of {@link + * #compare(ResolvedComponentInfo, ResolvedComponentInfo)} + */ + abstract int compare(ResolveInfo lhs, ResolveInfo rhs); + + /** + * Computes features for each target. This will be called before calls to {@link + * #getScore(ComponentName)} or {@link #compare(Object, Object)}, in order to prepare the + * comparator for those calls. Note that {@link #getScore(ComponentName)} uses {@link + * ComponentName}, so the implementation will have to be prepared to identify a {@link + * ResolvedComponentInfo} by {@link ComponentName}. {@link #beforeCompute()} will be called + * before doing any computing. + */ + public final void compute(List targets) { + beforeCompute(); + doCompute(targets); + } + + /** Implementation of compute called after {@link #beforeCompute()}. */ + abstract void doCompute(List targets); + + /** + * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} + * when {@link #compute(List)} was called before this. + */ + public abstract float getScore(ComponentName name); + + /** Handles result message sent to mHandler. */ + abstract void handleResultMessage(Message message); + + /** + * Reports to UsageStats what was chosen. + */ + public final void updateChooserCounts(String packageName, int userId, String action) { + if (mUsm != null) { + mUsm.reportChooserSelection(packageName, userId, mContentType, mAnnotations, action); + } + } + + /** + * Updates the model used to rank the componentNames. + * + *

    Default implementation does nothing, as we could have simple model that does not train + * online. + * + * @param componentName the component that the user clicked + */ + public void updateModel(ComponentName componentName) { + } + + /** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */ + void beforeCompute() { + if (DEBUG) Log.d(TAG, "Setting watchdog timer for " + WATCHDOG_TIMEOUT_MILLIS + "ms"); + if (mHandler == null) { + Log.d(TAG, "Error: Handler is Null; Needs to be initialized."); + return; + } + mHandler.sendEmptyMessageDelayed(RANKER_RESULT_TIMEOUT, WATCHDOG_TIMEOUT_MILLIS); + } + + /** + * Called when the {@link ResolverActivity} is destroyed. This calls {@link #afterCompute()}. If + * this call needs to happen at a different time during destroy, the method should be + * overridden. + */ + public void destroy() { + mHandler.removeMessages(RANKER_SERVICE_RESULT); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + mAfterCompute = null; + } + + /** + * Sort intents alphabetically based on package name. + */ + class AzInfoComparator implements Comparator { + Collator mCollator; + AzInfoComparator(Context context) { + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + } + + @Override + public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { + if (lhsp == null) { + return -1; + } else if (rhsp == null) { + return 1; + } + return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); + } + } + +} diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java new file mode 100644 index 00000000..c6bb2b85 --- /dev/null +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -0,0 +1,277 @@ +/* + * Copyright 2018 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.intentresolver.model; + +import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH; + +import android.annotation.Nullable; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.app.prediction.AppTargetEvent; +import android.app.prediction.AppTargetId; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.Message; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; + +/** + * Uses an {@link AppPredictor} to sort Resolver targets. If the AppPredictionService appears to be + * disabled by returning an empty sorted target list, {@link AppPredictionServiceResolverComparator} + * will fallback to using a {@link ResolverRankerServiceResolverComparator}. + */ +public class AppPredictionServiceResolverComparator extends AbstractResolverComparator { + + private static final String TAG = "APSResolverComparator"; + + private final AppPredictor mAppPredictor; + private final Context mContext; + private final Map mTargetRanks = new HashMap<>(); + private final Map mTargetScores = new HashMap<>(); + private final UserHandle mUser; + private final Intent mIntent; + private final String mReferrerPackage; + // If this is non-null (and this is not destroyed), it means APS is disabled and we should fall + // back to using the ResolverRankerService. + // TODO: responsibility for this fallback behavior can live outside of the AppPrediction client. + private ResolverRankerServiceResolverComparator mResolverRankerService; + private AppPredictionServiceComparatorModel mComparatorModel; + + public AppPredictionServiceResolverComparator( + Context context, + Intent intent, + String referrerPackage, + AppPredictor appPredictor, + UserHandle user, + ChooserActivityLogger chooserActivityLogger) { + super(context, intent); + mContext = context; + mIntent = intent; + mAppPredictor = appPredictor; + mUser = user; + mReferrerPackage = referrerPackage; + setChooserActivityLogger(chooserActivityLogger); + mComparatorModel = buildUpdatedModel(); + } + + @Override + int compare(ResolveInfo lhs, ResolveInfo rhs) { + return mComparatorModel.getComparator().compare(lhs, rhs); + } + + @Override + void doCompute(List targets) { + if (targets.isEmpty()) { + mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); + return; + } + List appTargets = new ArrayList<>(); + for (ResolvedComponentInfo target : targets) { + appTargets.add( + new AppTarget.Builder( + new AppTargetId(target.name.flattenToString()), + target.name.getPackageName(), + mUser) + .setClassName(target.name.getClassName()) + .build()); + } + mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(), + sortedAppTargets -> { + if (sortedAppTargets.isEmpty()) { + Log.i(TAG, "AppPredictionService disabled. Using resolver."); + // APS for chooser is disabled. Fallback to resolver. + mResolverRankerService = + new ResolverRankerServiceResolverComparator( + mContext, mIntent, mReferrerPackage, + () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), + getChooserActivityLogger()); + mComparatorModel = buildUpdatedModel(); + mResolverRankerService.compute(targets); + } else { + Log.i(TAG, "AppPredictionService response received"); + // Skip sending to Handler which takes extra time to dispatch messages. + handleResult(sortedAppTargets); + } + } + ); + } + + @Override + void handleResultMessage(Message msg) { + // Null value is okay if we have defaulted to the ResolverRankerService. + if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) { + final List sortedAppTargets = (List) msg.obj; + handleSortedAppTargets(sortedAppTargets); + } else if (msg.obj == null && mResolverRankerService == null) { + Log.e(TAG, "Unexpected null result"); + } + } + + private void handleResult(List sortedAppTargets) { + if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { + handleSortedAppTargets(sortedAppTargets); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + } + } + + private void handleSortedAppTargets(List sortedAppTargets) { + if (checkAppTargetRankValid(sortedAppTargets)) { + sortedAppTargets.forEach(target -> mTargetScores.put( + new ComponentName(target.getPackageName(), target.getClassName()), + target.getRank())); + } + for (int i = 0; i < sortedAppTargets.size(); i++) { + ComponentName componentName = new ComponentName( + sortedAppTargets.get(i).getPackageName(), + sortedAppTargets.get(i).getClassName()); + mTargetRanks.put(componentName, i); + Log.i(TAG, "handleSortedAppTargets, sortedAppTargets #" + i + ": " + componentName); + } + mComparatorModel = buildUpdatedModel(); + } + + private boolean checkAppTargetRankValid(List sortedAppTargets) { + for (AppTarget target : sortedAppTargets) { + if (target.getRank() != 0) { + return true; + } + } + return false; + } + + @Override + public float getScore(ComponentName name) { + return mComparatorModel.getScore(name); + } + + @Override + public void updateModel(ComponentName componentName) { + mComparatorModel.notifyOnTargetSelected(componentName); + } + + @Override + public void destroy() { + if (mResolverRankerService != null) { + mResolverRankerService.destroy(); + mResolverRankerService = null; + mComparatorModel = buildUpdatedModel(); + } + } + + /** + * Re-construct an {@code AppPredictionServiceComparatorModel} to replace the current model + * instance (if any) using the up-to-date {@code AppPredictionServiceResolverComparator} ivar + * values. + * + * TODO: each time we replace the model instance, we're either updating the model to use + * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars + * that wasn't available the last time the model was updated. For those latter cases, we should + * just avoid creating the model altogether until we have all the prerequisites we'll need. Then + * we can probably simplify the logic in {@code AppPredictionServiceComparatorModel} since we + * won't need to handle edge cases when the model data isn't fully prepared. + * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished + * initializing the first time and now want to adjust some data, but still need to wait for + * changes to propagate to the other ivars before rebuilding the model.) + */ + private AppPredictionServiceComparatorModel buildUpdatedModel() { + return new AppPredictionServiceComparatorModel( + mAppPredictor, mResolverRankerService, mUser, mTargetRanks); + } + + // TODO: Finish separating behaviors of AbstractResolverComparator, then (probably) make this a + // standalone class once clients are written in terms of ResolverComparatorModel. + static class AppPredictionServiceComparatorModel implements ResolverComparatorModel { + private final AppPredictor mAppPredictor; + private final ResolverRankerServiceResolverComparator mResolverRankerService; + private final UserHandle mUser; + private final Map mTargetRanks; // Treat as immutable. + + AppPredictionServiceComparatorModel( + AppPredictor appPredictor, + @Nullable ResolverRankerServiceResolverComparator resolverRankerService, + UserHandle user, + Map targetRanks) { + mAppPredictor = appPredictor; + mResolverRankerService = resolverRankerService; + mUser = user; + mTargetRanks = targetRanks; + } + + @Override + public Comparator getComparator() { + return (lhs, rhs) -> { + if (mResolverRankerService != null) { + return mResolverRankerService.compare(lhs, rhs); + } + Integer lhsRank = mTargetRanks.get(new ComponentName(lhs.activityInfo.packageName, + lhs.activityInfo.name)); + Integer rhsRank = mTargetRanks.get(new ComponentName(rhs.activityInfo.packageName, + rhs.activityInfo.name)); + if (lhsRank == null && rhsRank == null) { + return 0; + } else if (lhsRank == null) { + return -1; + } else if (rhsRank == null) { + return 1; + } + return lhsRank - rhsRank; + }; + } + + @Override + public float getScore(ComponentName name) { + if (mResolverRankerService != null) { + return mResolverRankerService.getScore(name); + } + Integer rank = mTargetRanks.get(name); + if (rank == null) { + Log.w(TAG, "Score requested for unknown component. Did you call compute yet?"); + return 0f; + } + int consecutiveSumOfRanks = (mTargetRanks.size() - 1) * (mTargetRanks.size()) / 2; + return 1.0f - (((float) rank) / consecutiveSumOfRanks); + } + + @Override + public void notifyOnTargetSelected(ComponentName componentName) { + if (mResolverRankerService != null) { + mResolverRankerService.updateModel(componentName); + return; + } + mAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder( + new AppTarget.Builder( + new AppTargetId(componentName.toString()), + componentName.getPackageName(), mUser) + .setClassName(componentName.getClassName()).build(), + ACTION_LAUNCH).build()); + } + } +} diff --git a/java/src/com/android/intentresolver/model/ResolverComparatorModel.java b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java new file mode 100644 index 00000000..3616a853 --- /dev/null +++ b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022 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.intentresolver.model; + +import android.content.ComponentName; +import android.content.pm.ResolveInfo; + +import java.util.Comparator; + +/** + * A ranking model for resolver targets, providing ordering and (optionally) numerical scoring. + * + * As required by the {@link Comparator} contract, objects returned by {@code getComparator()} must + * apply a total ordering on its inputs consistent across all calls to {@code Comparator#compare()}. + * Other query methods and ranking feedback should refer to that same ordering, so implementors are + * generally advised to "lock in" an immutable snapshot of their model data when this object is + * initialized (preferring to replace the entire {@code ResolverComparatorModel} instance if the + * backing data needs to be updated in the future). + */ +interface ResolverComparatorModel { + /** + * Get a {@code Comparator} that can be used to sort {@code ResolveInfo} targets according to + * the model ranking. + */ + Comparator getComparator(); + + /** + * Get the numerical score, if any, that the model assigns to the component with the specified + * {@code name}. Scores range from zero to one, with one representing the highest possible + * likelihood that the user will select that component as the target. Implementations that don't + * assign numerical scores are recommended to return a value of 0 for all components. + */ + float getScore(ComponentName name); + + /** + * Notify the model that the user selected a target. (Models may log this information, use it as + * a feedback signal for their ranking, etc.) Because the data in this + * {@code ResolverComparatorModel} instance is immutable, clients will need to get an up-to-date + * instance in order to see any changes in the ranking that might result from this feedback. + */ + void notifyOnTargetSelected(ComponentName componentName); +} diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java new file mode 100644 index 00000000..4382f109 --- /dev/null +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -0,0 +1,601 @@ +/* + * Copyright (C) 2015 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.intentresolver.model; + +import android.app.usage.UsageStats; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.metrics.LogMaker; +import android.os.IBinder; +import android.os.Message; +import android.os.RemoteException; +import android.os.UserHandle; +import android.service.resolver.IResolverRankerResult; +import android.service.resolver.IResolverRankerService; +import android.service.resolver.ResolverRankerService; +import android.service.resolver.ResolverTarget; +import android.util.Log; + +import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Ranks and compares packages based on usage stats and uses the {@link ResolverRankerService}. + */ +public class ResolverRankerServiceResolverComparator extends AbstractResolverComparator { + private static final String TAG = "RRSResolverComparator"; + + private static final boolean DEBUG = false; + + // One week + private static final long USAGE_STATS_PERIOD = 1000 * 60 * 60 * 24 * 7; + + private static final long RECENCY_TIME_PERIOD = 1000 * 60 * 60 * 12; + + private static final float RECENCY_MULTIPLIER = 2.f; + + // timeout for establishing connections with a ResolverRankerService. + private static final int CONNECTION_COST_TIMEOUT_MILLIS = 200; + + private final Collator mCollator; + private final Map mStats; + private final long mCurrentTime; + private final long mSinceTime; + private final LinkedHashMap mTargetsDict = new LinkedHashMap<>(); + private final String mReferrerPackage; + private final Object mLock = new Object(); + private ArrayList mTargets; + private String mAction; + private ComponentName mResolvedRankerName; + private ComponentName mRankerServiceName; + private IResolverRankerService mRanker; + private ResolverRankerServiceConnection mConnection; + private Context mContext; + private CountDownLatch mConnectSignal; + private ResolverRankerServiceComparatorModel mComparatorModel; + + public ResolverRankerServiceResolverComparator(Context context, Intent intent, + String referrerPackage, Runnable afterCompute, + ChooserActivityLogger chooserActivityLogger) { + super(context, intent); + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + mReferrerPackage = referrerPackage; + mContext = context; + + mCurrentTime = System.currentTimeMillis(); + mSinceTime = mCurrentTime - USAGE_STATS_PERIOD; + mStats = mUsm.queryAndAggregateUsageStats(mSinceTime, mCurrentTime); + mAction = intent.getAction(); + mRankerServiceName = new ComponentName(mContext, this.getClass()); + setCallBack(afterCompute); + setChooserActivityLogger(chooserActivityLogger); + + mComparatorModel = buildUpdatedModel(); + } + + @Override + public void handleResultMessage(Message msg) { + if (msg.what != RANKER_SERVICE_RESULT) { + return; + } + if (msg.obj == null) { + Log.e(TAG, "Receiving null prediction results."); + return; + } + final List receivedTargets = (List) msg.obj; + if (receivedTargets != null && mTargets != null + && receivedTargets.size() == mTargets.size()) { + final int size = mTargets.size(); + boolean isUpdated = false; + for (int i = 0; i < size; ++i) { + final float predictedProb = + receivedTargets.get(i).getSelectProbability(); + if (predictedProb != mTargets.get(i).getSelectProbability()) { + mTargets.get(i).setSelectProbability(predictedProb); + isUpdated = true; + } + } + if (isUpdated) { + mRankerServiceName = mResolvedRankerName; + mComparatorModel = buildUpdatedModel(); + } + } else { + Log.e(TAG, "Sizes of sent and received ResolverTargets diff."); + } + } + + // compute features for each target according to usage stats of targets. + @Override + public void doCompute(List targets) { + final long recentSinceTime = mCurrentTime - RECENCY_TIME_PERIOD; + + float mostRecencyScore = 1.0f; + float mostTimeSpentScore = 1.0f; + float mostLaunchScore = 1.0f; + float mostChooserScore = 1.0f; + + for (ResolvedComponentInfo target : targets) { + final ResolverTarget resolverTarget = new ResolverTarget(); + mTargetsDict.put(target.name, resolverTarget); + final UsageStats pkStats = mStats.get(target.name.getPackageName()); + if (pkStats != null) { + // Only count recency for apps that weren't the caller + // since the caller is always the most recent. + // Persistent processes muck this up, so omit them too. + if (!target.name.getPackageName().equals(mReferrerPackage) + && !isPersistentProcess(target)) { + final float recencyScore = + (float) Math.max(pkStats.getLastTimeUsed() - recentSinceTime, 0); + resolverTarget.setRecencyScore(recencyScore); + if (recencyScore > mostRecencyScore) { + mostRecencyScore = recencyScore; + } + } + final float timeSpentScore = (float) pkStats.getTotalTimeInForeground(); + resolverTarget.setTimeSpentScore(timeSpentScore); + if (timeSpentScore > mostTimeSpentScore) { + mostTimeSpentScore = timeSpentScore; + } + final float launchScore = (float) pkStats.mLaunchCount; + resolverTarget.setLaunchScore(launchScore); + if (launchScore > mostLaunchScore) { + mostLaunchScore = launchScore; + } + + float chooserScore = 0.0f; + if (pkStats.mChooserCounts != null && mAction != null + && pkStats.mChooserCounts.get(mAction) != null) { + chooserScore = (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mContentType, 0); + if (mAnnotations != null) { + final int size = mAnnotations.length; + for (int i = 0; i < size; i++) { + chooserScore += (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mAnnotations[i], 0); + } + } + } + if (DEBUG) { + if (mAction == null) { + Log.d(TAG, "Action type is null"); + } else { + Log.d(TAG, "Chooser Count of " + mAction + ":" + + target.name.getPackageName() + " is " + + Float.toString(chooserScore)); + } + } + resolverTarget.setChooserScore(chooserScore); + if (chooserScore > mostChooserScore) { + mostChooserScore = chooserScore; + } + } + } + + if (DEBUG) { + Log.d(TAG, "compute - mostRecencyScore: " + mostRecencyScore + + " mostTimeSpentScore: " + mostTimeSpentScore + + " mostLaunchScore: " + mostLaunchScore + + " mostChooserScore: " + mostChooserScore); + } + + mTargets = new ArrayList<>(mTargetsDict.values()); + for (ResolverTarget target : mTargets) { + final float recency = target.getRecencyScore() / mostRecencyScore; + setFeatures(target, recency * recency * RECENCY_MULTIPLIER, + target.getLaunchScore() / mostLaunchScore, + target.getTimeSpentScore() / mostTimeSpentScore, + target.getChooserScore() / mostChooserScore); + addDefaultSelectProbability(target); + if (DEBUG) { + Log.d(TAG, "Scores: " + target); + } + } + predictSelectProbabilities(mTargets); + + mComparatorModel = buildUpdatedModel(); + } + + @Override + public int compare(ResolveInfo lhs, ResolveInfo rhs) { + return mComparatorModel.getComparator().compare(lhs, rhs); + } + + @Override + public float getScore(ComponentName name) { + return mComparatorModel.getScore(name); + } + + // update ranking model when the connection to it is valid. + @Override + public void updateModel(ComponentName componentName) { + synchronized (mLock) { + mComparatorModel.notifyOnTargetSelected(componentName); + } + } + + // unbind the service and clear unhandled messges. + @Override + public void destroy() { + mHandler.removeMessages(RANKER_SERVICE_RESULT); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + if (mConnection != null) { + mContext.unbindService(mConnection); + mConnection.destroy(); + } + afterCompute(); + if (DEBUG) { + Log.d(TAG, "Unbinded Resolver Ranker."); + } + } + + // connect to a ranking service. + private void initRanker(Context context) { + synchronized (mLock) { + if (mConnection != null && mRanker != null) { + if (DEBUG) { + Log.d(TAG, "Ranker still exists; reusing the existing one."); + } + return; + } + } + Intent intent = resolveRankerService(); + if (intent == null) { + return; + } + mConnectSignal = new CountDownLatch(1); + mConnection = new ResolverRankerServiceConnection(mConnectSignal); + context.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE, UserHandle.SYSTEM); + } + + // resolve the service for ranking. + private Intent resolveRankerService() { + Intent intent = new Intent(ResolverRankerService.SERVICE_INTERFACE); + final List resolveInfos = mPm.queryIntentServices(intent, 0); + for (ResolveInfo resolveInfo : resolveInfos) { + if (resolveInfo == null || resolveInfo.serviceInfo == null + || resolveInfo.serviceInfo.applicationInfo == null) { + if (DEBUG) { + Log.d(TAG, "Failed to retrieve a ranker: " + resolveInfo); + } + continue; + } + ComponentName componentName = new ComponentName( + resolveInfo.serviceInfo.applicationInfo.packageName, + resolveInfo.serviceInfo.name); + try { + final String perm = mPm.getServiceInfo(componentName, 0).permission; + if (!ResolverRankerService.BIND_PERMISSION.equals(perm)) { + Log.w(TAG, "ResolverRankerService " + componentName + " does not require" + + " permission " + ResolverRankerService.BIND_PERMISSION + + " - this service will not be queried for " + + "ResolverRankerServiceResolverComparator. add android:permission=\"" + + ResolverRankerService.BIND_PERMISSION + "\"" + + " to the tag for " + componentName + + " in the manifest."); + continue; + } + if (PackageManager.PERMISSION_GRANTED != mPm.checkPermission( + ResolverRankerService.HOLD_PERMISSION, + resolveInfo.serviceInfo.packageName)) { + Log.w(TAG, "ResolverRankerService " + componentName + " does not hold" + + " permission " + ResolverRankerService.HOLD_PERMISSION + + " - this service will not be queried for " + + "ResolverRankerServiceResolverComparator."); + continue; + } + } catch (NameNotFoundException e) { + Log.e(TAG, "Could not look up service " + componentName + + "; component name not found"); + continue; + } + if (DEBUG) { + Log.d(TAG, "Succeeded to retrieve a ranker: " + componentName); + } + mResolvedRankerName = componentName; + intent.setComponent(componentName); + return intent; + } + return null; + } + + private class ResolverRankerServiceConnection implements ServiceConnection { + private final CountDownLatch mConnectSignal; + + ResolverRankerServiceConnection(CountDownLatch connectSignal) { + mConnectSignal = connectSignal; + } + + public final IResolverRankerResult resolverRankerResult = + new IResolverRankerResult.Stub() { + @Override + public void sendResult(List targets) throws RemoteException { + if (DEBUG) { + Log.d(TAG, "Sending Result back to Resolver: " + targets); + } + synchronized (mLock) { + final Message msg = Message.obtain(); + msg.what = RANKER_SERVICE_RESULT; + msg.obj = targets; + mHandler.sendMessage(msg); + } + } + }; + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + if (DEBUG) { + Log.d(TAG, "onServiceConnected: " + name); + } + synchronized (mLock) { + mRanker = IResolverRankerService.Stub.asInterface(service); + mComparatorModel = buildUpdatedModel(); + mConnectSignal.countDown(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (DEBUG) { + Log.d(TAG, "onServiceDisconnected: " + name); + } + synchronized (mLock) { + destroy(); + } + } + + public void destroy() { + synchronized (mLock) { + mRanker = null; + mComparatorModel = buildUpdatedModel(); + } + } + } + + @Override + void beforeCompute() { + super.beforeCompute(); + mTargetsDict.clear(); + mTargets = null; + mRankerServiceName = new ComponentName(mContext, this.getClass()); + mComparatorModel = buildUpdatedModel(); + mResolvedRankerName = null; + initRanker(mContext); + } + + // predict select probabilities if ranking service is valid. + private void predictSelectProbabilities(List targets) { + if (mConnection == null) { + if (DEBUG) { + Log.d(TAG, "Has not found valid ResolverRankerService; Skip Prediction"); + } + } else { + try { + mConnectSignal.await(CONNECTION_COST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + synchronized (mLock) { + if (mRanker != null) { + mRanker.predict(targets, mConnection.resolverRankerResult); + return; + } else { + if (DEBUG) { + Log.d(TAG, "Ranker has not been initialized; skip predict."); + } + } + } + } catch (InterruptedException e) { + Log.e(TAG, "Error in Wait for Service Connection."); + } catch (RemoteException e) { + Log.e(TAG, "Error in Predict: " + e); + } + } + afterCompute(); + } + + // adds select prob as the default values, according to a pre-trained Logistic Regression model. + private void addDefaultSelectProbability(ResolverTarget target) { + float sum = (2.5543f * target.getLaunchScore()) + + (2.8412f * target.getTimeSpentScore()) + + (0.269f * target.getRecencyScore()) + + (4.2222f * target.getChooserScore()); + target.setSelectProbability((float) (1.0 / (1.0 + Math.exp(1.6568f - sum)))); + } + + // sets features for each target + private void setFeatures(ResolverTarget target, float recencyScore, float launchScore, + float timeSpentScore, float chooserScore) { + target.setRecencyScore(recencyScore); + target.setLaunchScore(launchScore); + target.setTimeSpentScore(timeSpentScore); + target.setChooserScore(chooserScore); + } + + static boolean isPersistentProcess(ResolvedComponentInfo rci) { + if (rci != null && rci.getCount() > 0) { + int flags = rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags; + return (flags & ApplicationInfo.FLAG_PERSISTENT) != 0; + } + return false; + } + + /** + * Re-construct a {@code ResolverRankerServiceComparatorModel} to replace the current model + * instance (if any) using the up-to-date {@code ResolverRankerServiceResolverComparator} ivar + * values. + * + * TODO: each time we replace the model instance, we're either updating the model to use + * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars + * that wasn't available the last time the model was updated. For those latter cases, we should + * just avoid creating the model altogether until we have all the prerequisites we'll need. Then + * we can probably simplify the logic in {@code ResolverRankerServiceComparatorModel} since we + * won't need to handle edge cases when the model data isn't fully prepared. + * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished + * initializing the first time and now want to adjust some data, but still need to wait for + * changes to propagate to the other ivars before rebuilding the model.) + */ + private ResolverRankerServiceComparatorModel buildUpdatedModel() { + // TODO: we don't currently guarantee that the underlying target list/map won't be mutated, + // so the ResolverComparatorModel may provide inconsistent results. We should make immutable + // copies of the data (waiting for any necessary remaining data before creating the model). + return new ResolverRankerServiceComparatorModel( + mStats, + mTargetsDict, + mTargets, + mCollator, + mRanker, + mRankerServiceName, + (mAnnotations != null), + mPm); + } + + /** + * Implementation of a {@code ResolverComparatorModel} that provides the same ranking logic as + * the legacy {@code ResolverRankerServiceResolverComparator}, as a refactoring step toward + * removing the complex legacy API. + */ + static class ResolverRankerServiceComparatorModel implements ResolverComparatorModel { + private final Map mStats; // Treat as immutable. + private final Map mTargetsDict; // Treat as immutable. + private final List mTargets; // Treat as immutable. + private final Collator mCollator; + private final IResolverRankerService mRanker; + private final ComponentName mRankerServiceName; + private final boolean mAnnotationsUsed; + private final PackageManager mPm; + + // TODO: it doesn't look like we should have to pass both targets and targetsDict, but it's + // not written in a way that makes it clear whether we can derive one from the other (at + // least in this constructor). + ResolverRankerServiceComparatorModel( + Map stats, + Map targetsDict, + List targets, + Collator collator, + IResolverRankerService ranker, + ComponentName rankerServiceName, + boolean annotationsUsed, + PackageManager pm) { + mStats = stats; + mTargetsDict = targetsDict; + mTargets = targets; + mCollator = collator; + mRanker = ranker; + mRankerServiceName = rankerServiceName; + mAnnotationsUsed = annotationsUsed; + mPm = pm; + } + + @Override + public Comparator getComparator() { + // TODO: doCompute() doesn't seem to be concerned about null-checking mStats. Is that + // a bug there, or do we have a way of knowing it will be non-null under certain + // conditions? + return (lhs, rhs) -> { + if (mStats != null) { + final ResolverTarget lhsTarget = mTargetsDict.get(new ComponentName( + lhs.activityInfo.packageName, lhs.activityInfo.name)); + final ResolverTarget rhsTarget = mTargetsDict.get(new ComponentName( + rhs.activityInfo.packageName, rhs.activityInfo.name)); + + if (lhsTarget != null && rhsTarget != null) { + final int selectProbabilityDiff = Float.compare( + rhsTarget.getSelectProbability(), lhsTarget.getSelectProbability()); + + if (selectProbabilityDiff != 0) { + return selectProbabilityDiff > 0 ? 1 : -1; + } + } + } + + CharSequence sa = lhs.loadLabel(mPm); + if (sa == null) sa = lhs.activityInfo.name; + CharSequence sb = rhs.loadLabel(mPm); + if (sb == null) sb = rhs.activityInfo.name; + + return mCollator.compare(sa.toString().trim(), sb.toString().trim()); + }; + } + + @Override + public float getScore(ComponentName name) { + final ResolverTarget target = mTargetsDict.get(name); + if (target != null) { + return target.getSelectProbability(); + } + return 0; + } + + @Override + public void notifyOnTargetSelected(ComponentName componentName) { + if (mRanker != null) { + try { + int selectedPos = new ArrayList(mTargetsDict.keySet()) + .indexOf(componentName); + if (selectedPos >= 0 && mTargets != null) { + final float selectedProbability = getScore(componentName); + int order = 0; + for (ResolverTarget target : mTargets) { + if (target.getSelectProbability() > selectedProbability) { + order++; + } + } + logMetrics(order); + mRanker.train(mTargets, selectedPos); + } else { + if (DEBUG) { + Log.d(TAG, "Selected a unknown component: " + componentName); + } + } + } catch (RemoteException e) { + Log.e(TAG, "Error in Train: " + e); + } + } else { + if (DEBUG) { + Log.d(TAG, "Ranker is null; skip updateModel."); + } + } + } + + /** Records metrics for evaluation. */ + private void logMetrics(int selectedPos) { + if (mRankerServiceName != null) { + MetricsLogger metricsLogger = new MetricsLogger(); + LogMaker log = new LogMaker(MetricsEvent.ACTION_TARGET_SELECTED); + log.setComponentName(mRankerServiceName); + log.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, mAnnotationsUsed ? 1 : 0); + log.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, selectedPos); + metricsLogger.write(log); + } + } + } +} diff --git a/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java deleted file mode 100644 index 36058a6c..00000000 --- a/java/tests/src/com/android/intentresolver/AbstractResolverComparatorTest.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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.intentresolver; - -import static junit.framework.Assert.assertEquals; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ResolveInfo; -import android.os.Message; - -import androidx.test.InstrumentationRegistry; - -import org.junit.Test; - -import java.util.List; - -public class AbstractResolverComparatorTest { - - @Test - public void testPinned() { - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), new ResolveInfo() - ); - r1.setPinned(true); - - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() - ); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); - - assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); - assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); - } - - - @Test - public void testBothPinned() { - ResolveInfo pmInfo1 = new ResolveInfo(); - pmInfo1.activityInfo = new ActivityInfo(); - pmInfo1.activityInfo.packageName = "aaa"; - - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), pmInfo1); - r1.setPinned(true); - - ResolveInfo pmInfo2 = new ResolveInfo(); - pmInfo2.activityInfo = new ActivityInfo(); - pmInfo2.activityInfo.packageName = "zzz"; - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); - r2.setPinned(true); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); - - assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); - } - - private AbstractResolverComparator getTestComparator(Context context) { - Intent intent = new Intent(); - - AbstractResolverComparator testComparator = - new AbstractResolverComparator(context, intent) { - - @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { - // Used for testing pinning, so we should never get here --- the overrides - // should determine the result instead. - return 1; - } - - @Override - void doCompute(List targets) {} - - @Override - float getScore(ComponentName name) { - return 0; - } - - @Override - void handleResultMessage(Message message) {} - }; - return testComparator; - } - -} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java new file mode 100644 index 00000000..448718cd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -0,0 +1,107 @@ +/* + * 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.intentresolver.model; + +import static junit.framework.Assert.assertEquals; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ResolveInfo; +import android.os.Message; + +import androidx.test.InstrumentationRegistry; + +import com.android.intentresolver.ResolverActivity; + +import org.junit.Test; + +import java.util.List; + +public class AbstractResolverComparatorTest { + + @Test + public void testPinned() { + ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), new ResolveInfo() + ); + r1.setPinned(true); + + ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() + ); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context); + + assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); + assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); + } + + + @Test + public void testBothPinned() { + ResolveInfo pmInfo1 = new ResolveInfo(); + pmInfo1.activityInfo = new ActivityInfo(); + pmInfo1.activityInfo.packageName = "aaa"; + + ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), pmInfo1); + r1.setPinned(true); + + ResolveInfo pmInfo2 = new ResolveInfo(); + pmInfo2.activityInfo = new ActivityInfo(); + pmInfo2.activityInfo.packageName = "zzz"; + ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); + r2.setPinned(true); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context); + + assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); + } + + private AbstractResolverComparator getTestComparator(Context context) { + Intent intent = new Intent(); + + AbstractResolverComparator testComparator = + new AbstractResolverComparator(context, intent) { + + @Override + int compare(ResolveInfo lhs, ResolveInfo rhs) { + // Used for testing pinning, so we should never get here --- the overrides + // should determine the result instead. + return 1; + } + + @Override + void doCompute(List targets) {} + + @Override + public float getScore(ComponentName name) { + return 0; + } + + @Override + void handleResultMessage(Message message) {} + }; + return testComparator; + } + +} -- cgit v1.2.3-59-g8ed1b From 5ff8942a8e160269c98deefbfb16e53b812b74e9 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 9 Nov 2022 11:28:07 -0500 Subject: Extract ChooserActivity.RoundedRectImageView This is a trivial code move (diffs show only minor formatting changes [EDIT: a later snapshot also gets rid of the `` tags in our layouts, which were only necessary to reference this as an inner class, in favor of the more common practice of using an XML element with the same fully-qualified name). As an inner class, it had already been marked `static`, so there's no correctness concern (EDIT: on the Java side...) The only "client" is the (soon-to-be-extracted) preview UI, but instead of moving this to an inner class of the extracted component, it seems reasonable for this class to stand alone -- it has a simple design that's plausibly reusable in other applications, since it doesn't really encapsulate any requirements that are specific to Sharesheet. Bug: 202167050 Test: atest IntentResolverUnitTests Change-Id: I63833626925896a0baaa06d729233cd7037ac730 --- java/res/layout/chooser_grid_preview_file.xml | 2 +- java/res/layout/chooser_grid_preview_image.xml | 8 +- java/res/layout/chooser_grid_preview_text.xml | 2 +- .../android/intentresolver/ChooserActivity.java | 110 +---------------- .../widget/RoundedRectImageView.java | 131 +++++++++++++++++++++ 5 files changed, 138 insertions(+), 115 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/RoundedRectImageView.java (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 6d2e76a0..c3392704 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -37,7 +37,7 @@ android:layout_marginBottom="@dimen/chooser_view_spacing" android:id="@androidprv:id/content_preview_file_layout"> - - - - - - 0) { - this.mExtraImageCount = "+" + count; - } else { - this.mExtraImageCount = null; - } - } - - @Override - protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { - super.onSizeChanged(width, height, oldWidth, oldHeight); - updatePath(width, height); - } - - @Override - protected void onDraw(Canvas canvas) { - if (mRadius != 0) { - canvas.clipPath(mPath); - } - - super.onDraw(canvas); - - int x = getPaddingLeft(); - int y = getPaddingRight(); - int width = getWidth() - getPaddingRight() - getPaddingLeft(); - int height = getHeight() - getPaddingBottom() - getPaddingTop(); - if (mExtraImageCount != null) { - canvas.drawRect(x, y, width, height, mOverlayPaint); - - int xPos = canvas.getWidth() / 2; - int yPos = (int) ((canvas.getHeight() / 2.0f) - - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f)); - - canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint); - } - - canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint); - } - } - /** * A helper class to track app's readiness for the scene transition animation. * The app is ready when both the image is laid out and the drawer offset is calculated. diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java new file mode 100644 index 00000000..cf7bd543 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2008 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.intentresolver.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.android.intentresolver.R; + +/** + * {@link ImageView} that rounds the corners around the presented image while obeying view padding. + */ +public class RoundedRectImageView extends ImageView { + private int mRadius = 0; + private Path mPath = new Path(); + private Paint mOverlayPaint = new Paint(0); + private Paint mRoundRectPaint = new Paint(0); + private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private String mExtraImageCount = null; + + public RoundedRectImageView(Context context) { + super(context); + } + + public RoundedRectImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public RoundedRectImageView( + Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + + mOverlayPaint.setColor(0x99000000); + mOverlayPaint.setStyle(Paint.Style.FILL); + + mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider)); + mRoundRectPaint.setStyle(Paint.Style.STROKE); + mRoundRectPaint.setStrokeWidth(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_border)); + + mTextPaint.setColor(Color.WHITE); + mTextPaint.setTextSize(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size)); + mTextPaint.setTextAlign(Paint.Align.CENTER); + } + + private void updatePath(int width, int height) { + mPath.reset(); + + int imageWidth = width - getPaddingRight() - getPaddingLeft(); + int imageHeight = height - getPaddingBottom() - getPaddingTop(); + mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius, + mRadius, Path.Direction.CW); + } + + /** + * Sets the corner radius on all corners + * + * param radius 0 for no radius, > 0 for a visible corner radius + */ + public void setRadius(int radius) { + mRadius = radius; + updatePath(getWidth(), getHeight()); + } + + /** + * Display an overlay with extra image count on 3rd image + */ + public void setExtraImageCount(int count) { + if (count > 0) { + this.mExtraImageCount = "+" + count; + } else { + this.mExtraImageCount = null; + } + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + updatePath(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRadius != 0) { + canvas.clipPath(mPath); + } + + super.onDraw(canvas); + + int x = getPaddingLeft(); + int y = getPaddingRight(); + int width = getWidth() - getPaddingRight() - getPaddingLeft(); + int height = getHeight() - getPaddingBottom() - getPaddingTop(); + if (mExtraImageCount != null) { + canvas.drawRect(x, y, width, height, mOverlayPaint); + + int xPos = canvas.getWidth() / 2; + int yPos = (int) ((canvas.getHeight() / 2.0f) + - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f)); + + canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint); + } + + canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint); + } +} -- cgit v1.2.3-59-g8ed1b From e2463f3c11eeeb58ea3966b7872201c47a688bbb Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 9 Nov 2022 16:15:56 -0500 Subject: Clarify/simplify ContentPreviewCoordinator lifecycle This is *not* an obviously-safe "pure" refactor; there are a few notable logic changes(*), but they should cause no observable behavior changes in practice. The simplified lifecycle (deferred assignment -> pre-initialization) shows that this component has essential responsibilities to `ChooserActivity` in ensuring that asynchronous tasks are shut down when the activity is destroyed. Minor refactoring in this CL shows that the component is otherwise injectable as a delegate in our preview-loading "factories," to be extracted in another upcoming cleanup CL; a new (temporarily-homed) interface in this CL makes that delegation API explicit. I extracted the implementation to an outer class to chip away at the `ChooserActivity` monolith; to draw attention to the coordinator's business-logic responsibilities in defining success/failure conditions (in addition to the UI responsibilities that ayepin@ suggests could be separated from the rest of the coordinator component); and to provide a clearer line to cut away if we (hopefully) eventually decide to move off of this particular processing model altogether. For more discussion see comments on ag/20390247, summarized below. * [Logic changes] 1. We now guarantee at most one `ContentPreviewCoordinator` instance. This is unlikely to differ from the earlier behavior, but we would've had no checks before a potential re-assignment. If one were to occur, we would've lost track of any pending tasks that the previous instance(s) were responsible for cancelling. (By comparison, after this CL, multiple instances would instead queue their requests in a shared coordinator and influence each other's "batch" timeout logic -- it's debatable whether that's correct, but it's ultimately insignificant either way). 2. Even if we never re-assigned any extra coordinator instances, the model prior to this CL was effectively "lazy initialization" of the coordinator, but we now initialize a coordinator "eagerly" to simplify the lifecycle. While the earlier model was technically "lazy," it was still computed during the `onCreate()` flow, so this doesn't make much difference -- except notably, we'll now initialize a coordinator for every Sharesheet session, even if we don't end up building a preview UI. The coordinator class is lightweight if it's never used, so this doesn't seem like a problem. 3. The `findViewById()` queries in `ContentPreviewCoordinator` now have a broader root for their search so that they can work for both kinds of previews ("sticky" and "list item"), and we can share the one eagerly-initialized instance. We can always change the API if we need more specificity later, but for now it seems like we can make this change with no repercussions for our app behavior. For more detail see ag/20390247, but essentially: a. The IDs of the views we search for are explicitly named for the purpose of content previews and won't plausibly be used for anything else. Thus, b. The broadened queries could only be ambiguous if we were to display more than one content preview in our hierarchy. But: c. We show at most one content preview: either the "sticky" preview in the `ChooserActivity` root layout, or the "list item" preview that's built into the list *when we have only one profile to show*, and never both (gated on the result of `shouldShowTabs()`). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I0dd6e48ee92845ce68d6dcf8e84e272b11caf496 --- Android.bp | 2 + .../android/intentresolver/ChooserActivity.java | 275 +++++++++------------ .../ChooserContentPreviewCoordinator.java | 179 ++++++++++++++ 3 files changed, 295 insertions(+), 161 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java (limited to 'java/src') diff --git a/Android.bp b/Android.bp index c2620c49..521d5626 100644 --- a/Android.bp +++ b/Android.bp @@ -46,10 +46,12 @@ android_library { static_libs: [ "androidx.annotation_annotation", + "androidx.concurrent_concurrent-futures", "androidx.recyclerview_recyclerview", "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", + "guava", ], plugins: ["java_api_finder"], diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3eb30f57..d954104e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -66,7 +66,6 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; -import android.os.Message; import android.os.Parcelable; import android.os.PatternMatcher; import android.os.ResultReceiver; @@ -143,7 +142,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Consumer; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Supplier; /** @@ -281,7 +282,11 @@ public class ChooserActivity extends ResolverActivity implements protected static final int CONTENT_PREVIEW_TEXT = 3; protected MetricsLogger mMetricsLogger; - private ContentPreviewCoordinator mPreviewCoord; + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); + + @Nullable + private ChooserContentPreviewCoordinator mPreviewCoord; + private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting @@ -297,118 +302,28 @@ public class ChooserActivity extends ResolverActivity implements new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); - private static class ContentPreviewCoordinator { - - /* public */ ContentPreviewCoordinator( - ChooserActivity chooserActivity, - View parentView, - Runnable onFailCallback, - Consumer onSingleImageSuccessCallback) { - this.mChooserActivity = chooserActivity; - this.mParentView = parentView; - this.mOnFailCallback = onFailCallback; - this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; - - this.mImageLoadTimeoutMillis = - chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); - } - - public void cancelLoads() { - mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); - mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); - } - - private static final int IMAGE_FADE_IN_MILLIS = 150; - private static final int IMAGE_LOAD_TIMEOUT = 1; - private static final int IMAGE_LOAD_INTO_VIEW = 2; - - private final ChooserActivity mChooserActivity; - private final View mParentView; - private final Runnable mOnFailCallback; - private final Consumer mOnSingleImageSuccessCallback; - private final int mImageLoadTimeoutMillis; - - private boolean mAtLeastOneLoaded = false; - - private final Handler mHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - if (mChooserActivity.isFinishing()) { - return; - } - - if (msg.what == IMAGE_LOAD_TIMEOUT) { - // If at least one image loads within the timeout period, allow other loads to - // continue. (I.e., only fail if no images have loaded by the timeout event.) - if (!mAtLeastOneLoaded) { - mOnFailCallback.run(); - } - return; - } - - // TODO: switch off using `Handler`. For now the following conditions implicitly - // rely on the knowledge that we only have two message types (and so after the guard - // clause above, we know this is an `IMAGE_LOAD_INTO_VIEW` message). - - RoundedRectImageView imageView = mParentView.findViewById(msg.arg1); - if (msg.obj != null) { - onImageLoaded((Bitmap) msg.obj, imageView, msg.arg2); - } else { - imageView.setVisibility(View.GONE); - if (!mAtLeastOneLoaded) { - // TODO: this looks like a race condition. We know that this specific image - // failed (i.e. it got a null Bitmap), but we'll only report that to the - // client (thereby failing out our pending loads) if we haven't yet - // succeeded in loading some other non-null Bitmap. But there could be other - // pending loads that would've returned non-null within the timeout window, - // except they end up (effectively) cancelled because this one single-image - // load "finished" (failed) faster. The outcome of that race may be fairly - // predictable (since we *might* imagine that the nulls would usually "load" - // faster?), but it's not guaranteed since the loads are queued in - // `AsyncTask.THREAD_POOL_EXECUTOR` (i.e., in parallel). One option we might - // prefer for more deterministic behavior: don't signal the failure callback - // on a single-image load unless there are no other loads currently pending. - mOnFailCallback.run(); - } - } - } - }; - - private void onImageLoaded( - @NonNull Bitmap image, - RoundedRectImageView imageView, - int extraImageCount) { - mAtLeastOneLoaded = true; - - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - - if (extraImageCount > 0) { - imageView.setExtraImageCount(extraImageCount); - } - - mOnSingleImageSuccessCallback.accept(imageView); - } - - private void loadUriIntoView( - final int imageViewResourceId, final Uri uri, final int extraImages) { - mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis); - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - int size = mChooserActivity.getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - final Bitmap bmp = mChooserActivity.loadThumbnail(uri, new Size(size, size)); - final Message msg = mHandler.obtainMessage( - IMAGE_LOAD_INTO_VIEW, imageViewResourceId, extraImages, bmp); - mHandler.sendMessage(msg); - }); - } + /** + * Delegate to handle background resource loads that are dependencies of content previews. + * + * TODO: move to an inner class of the (to-be-created) new component for content previews. + */ + public interface ContentPreviewCoordinator { + /** + * Request that an image be loaded in the background and set into a view. + * + * @param viewProvider A delegate that will be called exactly once upon completion of the + * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be + * populated with the result (if the load was successful) or hidden (if the load failed). If + * this returns null, the load is discarded as a failure. + * @param imageUri The {@link Uri} of the image to load. + * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} + * if the image loads successfully. + * + * TODO: it looks like clients are probably capable of passing the view directly, but the + * deferred computation here is a closer match to the legacy model for now. + */ + void loadUriIntoView( + Callable viewProvider, Uri imageUri, int extraImages); } private void setupPreDrawForSharedElementTransition(View v) { @@ -565,6 +480,13 @@ public class ChooserActivity extends ResolverActivity implements this, target.getStringExtra(Intent.EXTRA_TEXT), getTargetIntentFilter(target))); + + mPreviewCoord = new ChooserContentPreviewCoordinator( + mBackgroundThreadPoolExecutor, + this, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); + super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, null, false); @@ -973,10 +895,12 @@ public class ChooserActivity extends ResolverActivity implements * @param parent reference to the parent container where the view should be attached to * @return content preview view */ - protected ViewGroup createContentPreviewView(ViewGroup parent) { + protected ViewGroup createContentPreviewView( + ViewGroup parent, ContentPreviewCoordinator previewCoord) { Intent targetIntent = getTargetIntent(); int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); - return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent); + return displayContentPreview( + previewType, targetIntent, getLayoutInflater(), parent, previewCoord); } @VisibleForTesting @@ -1182,19 +1106,26 @@ public class ChooserActivity extends ResolverActivity implements parent.addView(b, lp); } - private ViewGroup displayContentPreview(@ContentPreviewType int previewType, - Intent targetIntent, LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayContentPreview( + @ContentPreviewType int previewType, + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup layout = null; switch (previewType) { case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview(targetIntent, layoutInflater, parent); + layout = displayTextContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview(targetIntent, layoutInflater, parent); + layout = displayImageContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview(targetIntent, layoutInflater, parent); + layout = displayFileContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; default: Log.e(TAG, "Unexpected content preview type: " + previewType); @@ -1210,8 +1141,11 @@ public class ChooserActivity extends ResolverActivity implements return layout; } - private ViewGroup displayTextContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { + private ViewGroup displayTextContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); @@ -1252,20 +1186,22 @@ public class ChooserActivity extends ResolverActivity implements if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - mPreviewCoord = new ContentPreviewCoordinator( - this, - contentPreviewLayout, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + previewThumbnail, + 0); } } return contentPreviewLayout; } - private ViewGroup displayImageContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { + private ViewGroup displayImageContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area); @@ -1276,18 +1212,16 @@ public class ChooserActivity extends ResolverActivity implements addActionButton(actionRow, createNearbyButton(targetIntent)); addActionButton(actionRow, createEditButton(targetIntent)); - mPreviewCoord = new ContentPreviewCoordinator( - this, - contentPreviewLayout, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, uri, 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + uri, + 0); } else { ContentResolver resolver = getContentResolver(); @@ -1308,16 +1242,29 @@ public class ChooserActivity extends ResolverActivity implements imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, imageUris.get(0), 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + imageUris.get(0), + 0); if (imageUris.size() == 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_large, - imageUris.get(1), 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_large), + imageUris.get(1), + 0); } else if (imageUris.size() > 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_small, - imageUris.get(1), 0); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_3_small, - imageUris.get(2), imageUris.size() - 3); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_small), + imageUris.get(1), + 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_3_small), + imageUris.get(2), + imageUris.size() - 3); } } @@ -1388,9 +1335,11 @@ public class ChooserActivity extends ResolverActivity implements + "documentation"); } - private ViewGroup displayFileContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { - + private ViewGroup displayFileContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); @@ -1402,7 +1351,7 @@ public class ChooserActivity extends ResolverActivity implements String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout); + loadFileUriIntoView(uri, contentPreviewLayout, previewCoord); } else { List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); int uriCount = uris.size(); @@ -1414,7 +1363,7 @@ public class ChooserActivity extends ResolverActivity implements + "preview area"); return contentPreviewLayout; } else if (uriCount == 1) { - loadFileUriIntoView(uris.get(0), contentPreviewLayout); + loadFileUriIntoView(uris.get(0), contentPreviewLayout, previewCoord); } else { FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver()); int remUriCount = uriCount - 1; @@ -1444,19 +1393,19 @@ public class ChooserActivity extends ResolverActivity implements return contentPreviewLayout; } - private void loadFileUriIntoView(final Uri uri, final View parent) { + private void loadFileUriIntoView( + final Uri uri, final View parent, final ContentPreviewCoordinator previewCoord) { FileInfo fileInfo = extractFileInfo(uri, getContentResolver()); TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename); fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - mPreviewCoord = new ContentPreviewCoordinator( - this, - parent, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0); + previewCoord.loadUriIntoView( + () -> parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + uri, + 0); } else { View thumbnailView = parent.findViewById(com.android.internal.R.id.content_preview_file_thumbnail); thumbnailView.setVisibility(View.GONE); @@ -1544,7 +1493,7 @@ public class ChooserActivity extends ResolverActivity implements mRefinementResultReceiver = null; } - if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); + mBackgroundThreadPoolExecutor.shutdownNow(); destroyProfileRecords(); } @@ -2675,7 +2624,8 @@ public class ChooserActivity extends ResolverActivity implements // then always preload it to avoid subsequent resizing of the share sheet. ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + ViewGroup contentPreviewView = + createContentPreviewView(contentPreviewContainer, mPreviewCoord); contentPreviewContainer.addView(contentPreviewView); } } @@ -3031,7 +2981,10 @@ public class ChooserActivity extends ResolverActivity implements public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder(createContentPreviewView(parent), false, viewType); + return new ItemViewHolder( + createContentPreviewView(parent, mPreviewCoord), + false, + viewType); case VIEW_TYPE_PROFILE: return new ItemViewHolder(createProfileView(parent), false, viewType); case VIEW_TYPE_AZ_LABEL: diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java new file mode 100644 index 00000000..509f8884 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2008 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.intentresolver; + +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.NonNull; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Handler; +import android.util.Size; +import android.view.View; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; + +import com.android.intentresolver.widget.RoundedRectImageView; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +/** + * Delegate to manage deferred resource loads for content preview assets, while + * implementing Chooser's application logic for determining timeout/success/failure conditions. + */ +public class ChooserContentPreviewCoordinator implements ChooserActivity.ContentPreviewCoordinator { + public ChooserContentPreviewCoordinator( + ExecutorService backgroundExecutor, + ChooserActivity chooserActivity, + Runnable onFailCallback, + Consumer onSingleImageSuccessCallback) { + this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); + this.mChooserActivity = chooserActivity; + this.mOnFailCallback = onFailCallback; + this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; + + this.mImageLoadTimeoutMillis = + chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); + } + + @Override + public void loadUriIntoView( + final Callable deferredImageViewProvider, + final Uri imageUri, + final int extraImageCount) { + final int size = mChooserActivity.getResources().getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen); + + mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); + + ListenableFuture bitmapFuture = mBackgroundExecutor.submit( + () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); + + Futures.addCallback( + bitmapFuture, + new FutureCallback() { + @Override + public void onSuccess(Bitmap loadedBitmap) { + try { + onLoadCompleted( + deferredImageViewProvider.call(), + loadedBitmap, + extraImageCount); + } catch (Exception e) { /* unimportant */ } + } + + @Override + public void onFailure(Throwable t) {} + }, + mHandler::post); + } + + private static final int IMAGE_FADE_IN_MILLIS = 150; + + private final ChooserActivity mChooserActivity; + private final ListeningExecutorService mBackgroundExecutor; + private final Runnable mOnFailCallback; + private final Consumer mOnSingleImageSuccessCallback; + private final int mImageLoadTimeoutMillis; + + // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a + // `ScheduledExecutorService` that posts to the UI thread unless we use Dagger. Eventually we'll + // use Dagger and can inject this as a `@UiThread ScheduledExecutorService`. + private final Handler mHandler = new Handler(); + + private boolean mAtLeastOneLoaded = false; + + @MainThread + private void onWatchdogTimeout() { + if (mChooserActivity.isFinishing()) { + return; + } + + // If at least one image loads within the timeout period, allow other loads to continue. + if (!mAtLeastOneLoaded) { + mOnFailCallback.run(); + } + } + + @MainThread + private void onLoadCompleted( + @Nullable RoundedRectImageView imageView, + @Nullable Bitmap loadedBitmap, + int extraImageCount) { + if (mChooserActivity.isFinishing()) { + return; + } + + // TODO: legacy logic didn't handle a possible null view; handle the same as other + // single-image failures for now (i.e., this is also a factor in the "race" TODO below). + boolean thisLoadSucceeded = (imageView != null) && (loadedBitmap != null); + mAtLeastOneLoaded |= thisLoadSucceeded; + + // TODO: this looks like a race condition. We may know that this specific image failed (i.e. + // it got a null Bitmap), but we'll only report that to the client (thereby failing out our + // pending loads) if we haven't yet succeeded in loading some other non-null Bitmap. But + // there could be other pending loads that would've returned non-null within the timeout + // window, except they end up (effectively) cancelled because this one single-image load + // "finished" (failed) faster. The outcome of that race may be fairly predictable (since we + // *might* imagine that the nulls would usually "load" faster?), but it's not guaranteed + // since the loads are queued in a thread pool (i.e., in parallel). One option for more + // deterministic behavior: don't signal the failure callback on a single-image load unless + // there are no other loads currently pending. + boolean wholeBatchFailed = !mAtLeastOneLoaded; + + if (thisLoadSucceeded) { + onImageLoadedSuccessfully(loadedBitmap, imageView, extraImageCount); + } else if (imageView != null) { + imageView.setVisibility(View.GONE); + } + + if (wholeBatchFailed) { + mOnFailCallback.run(); + } + } + + @MainThread + private void onImageLoadedSuccessfully( + @NonNull Bitmap image, + RoundedRectImageView imageView, + int extraImageCount) { + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + + if (extraImageCount > 0) { + imageView.setExtraImageCount(extraImageCount); + } + + mOnSingleImageSuccessCallback.accept(imageView); + } +} -- cgit v1.2.3-59-g8ed1b From 44b7053c23b2aa9ff5762e3fa67d1b1e4b28b24f Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 10 Nov 2022 16:32:39 -0500 Subject: Extract ChooserActivity "content preview" logic. This is sort of a crude first pass at pulling out a batch of related methods with a similar overall structure that's reasonably self-contained (modulo the new delegate interfaces introduced in this CL). No attempt was made at introducing new abstractions for the preview; this follows the factory methods that were already present in ChooserActivity. Other methods that remain in ChooserActivity may be better fit for migration to the new component, pending further design work that's out-of-scope for this CL. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ieacffb3d184e60609a1a5e75542358a765e59ae3 --- .../android/intentresolver/ChooserActivity.java | 444 ++--------------- .../intentresolver/ChooserActivityLogger.java | 6 +- .../ChooserContentPreviewCoordinator.java | 3 +- .../intentresolver/ChooserContentPreviewUi.java | 539 +++++++++++++++++++++ .../intentresolver/ChooserActivityLoggerTest.java | 2 +- 5 files changed, 598 insertions(+), 396 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserContentPreviewUi.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index d954104e..558dfcf7 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -18,8 +18,6 @@ package com.android.intentresolver; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import static java.lang.annotation.RetentionPolicy.SOURCE; - import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; @@ -74,15 +72,11 @@ import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageManager; import android.provider.DeviceConfig; -import android.provider.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; import android.provider.Settings; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; import android.util.Log; -import android.util.PluralsMessageFormatter; import android.util.Size; import android.util.Slog; import android.util.SparseArray; @@ -100,7 +94,6 @@ import android.view.animation.Animation; import android.view.animation.DecelerateInterpolator; import android.view.animation.LinearInterpolator; import android.widget.Button; -import android.widget.ImageView; import android.widget.Space; import android.widget.TextView; @@ -118,7 +111,6 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.widget.ResolverDrawerLayout; -import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; @@ -142,7 +134,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Supplier; @@ -270,22 +261,12 @@ public class ChooserActivity extends ResolverActivity implements private SharedPreferences mPinnedSharedPrefs; private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; - @Retention(SOURCE) - @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) - private @interface ContentPreviewType { - } - - // Starting at 1 since 0 is considered "undefined" for some of the database transformations - // of tron logs. - protected static final int CONTENT_PREVIEW_IMAGE = 1; - protected static final int CONTENT_PREVIEW_FILE = 2; - protected static final int CONTENT_PREVIEW_TEXT = 3; protected MetricsLogger mMetricsLogger; private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); @Nullable - private ChooserContentPreviewCoordinator mPreviewCoord; + private ChooserContentPreviewCoordinator mPreviewCoordinator; private int mScrollStatus = SCROLL_STATUS_IDLE; @@ -302,30 +283,6 @@ public class ChooserActivity extends ResolverActivity implements new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); - /** - * Delegate to handle background resource loads that are dependencies of content previews. - * - * TODO: move to an inner class of the (to-be-created) new component for content previews. - */ - public interface ContentPreviewCoordinator { - /** - * Request that an image be loaded in the background and set into a view. - * - * @param viewProvider A delegate that will be called exactly once upon completion of the - * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be - * populated with the result (if the load was successful) or hidden (if the load failed). If - * this returns null, the load is discarded as a failure. - * @param imageUri The {@link Uri} of the image to load. - * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} - * if the image loads successfully. - * - * TODO: it looks like clients are probably capable of passing the view directly, but the - * deferred computation here is a closer match to the legacy model for now. - */ - void loadUriIntoView( - Callable viewProvider, Uri imageUri, int extraImages); - } - private void setupPreDrawForSharedElementTransition(View v) { v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override @@ -481,7 +438,7 @@ public class ChooserActivity extends ResolverActivity implements target.getStringExtra(Intent.EXTRA_TEXT), getTargetIntentFilter(target))); - mPreviewCoord = new ChooserContentPreviewCoordinator( + mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, this, this::hideContentPreview, @@ -536,7 +493,8 @@ public class ChooserActivity extends ResolverActivity implements mCallerChooserTargets == null ? 0 : mCallerChooserTargets.length, initialIntents == null ? 0 : initialIntents.length, isWorkProfile(), - findPreferredContentPreview(getTargetIntent(), getContentResolver()), + ChooserContentPreviewUi.findPreferredContentPreview( + getTargetIntent(), getContentResolver(), this::isImageType), target.getAction() ); mDirectShareShortcutInfoCache = new HashMap<>(); @@ -896,11 +854,49 @@ public class ChooserActivity extends ResolverActivity implements * @return content preview view */ protected ViewGroup createContentPreviewView( - ViewGroup parent, ContentPreviewCoordinator previewCoord) { + ViewGroup parent, + ChooserContentPreviewUi.ContentPreviewCoordinator previewCoordinator) { Intent targetIntent = getTargetIntent(); - int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); - return displayContentPreview( - previewType, targetIntent, getLayoutInflater(), parent, previewCoord); + int previewType = ChooserContentPreviewUi.findPreferredContentPreview( + targetIntent, getContentResolver(), this::isImageType); + + ChooserContentPreviewUi.ActionButtonFactory buttonFactory = + new ChooserContentPreviewUi.ActionButtonFactory() { + @Override + public Button createCopyButton() { + return ChooserActivity.this.createCopyButton(); + } + + @Override + public Button createEditButton() { + return ChooserActivity.this.createEditButton(targetIntent); + } + + @Override + public Button createNearbyButton() { + return ChooserActivity.this.createNearbyButton(targetIntent); + } + }; + + ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( + previewType, + targetIntent, + getResources(), + getLayoutInflater(), + buttonFactory, + parent, + previewCoordinator, + getContentResolver(), + this::isImageType); + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } + + return layout; } @VisibleForTesting @@ -1106,181 +1102,6 @@ public class ChooserActivity extends ResolverActivity implements parent.addView(b, lp); } - private ViewGroup displayContentPreview( - @ContentPreviewType int previewType, - Intent targetIntent, - LayoutInflater layoutInflater, - ViewGroup parent, - ContentPreviewCoordinator previewCoord) { - ViewGroup layout = null; - - switch (previewType) { - case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview( - targetIntent, layoutInflater, parent, previewCoord); - break; - case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview( - targetIntent, layoutInflater, parent, previewCoord); - break; - case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview( - targetIntent, layoutInflater, parent, previewCoord); - break; - default: - Log.e(TAG, "Unexpected content preview type: " + previewType); - } - - if (layout != null) { - adjustPreviewWidth(getResources().getConfiguration().orientation, layout); - } - if (previewType != CONTENT_PREVIEW_IMAGE) { - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - } - - return layout; - } - - private ViewGroup displayTextContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - ViewGroup parent, - ContentPreviewCoordinator previewCoord) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_text, parent, false); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - addActionButton(actionRow, createCopyButton()); - addActionButton(actionRow, createNearbyButton(targetIntent)); - - CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (sharingText == null) { - contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_text_layout).setVisibility( - View.GONE); - } else { - TextView textView = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_text); - textView.setText(sharingText); - } - - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); - if (TextUtils.isEmpty(previewTitle)) { - contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_title_layout).setVisibility( - View.GONE); - } else { - TextView previewTitleView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_title); - previewTitleView.setText(previewTitle); - - ClipData previewData = targetIntent.getClipData(); - Uri previewThumbnail = null; - if (previewData != null) { - if (previewData.getItemCount() > 0) { - ClipData.Item previewDataItem = previewData.getItemAt(0); - previewThumbnail = previewDataItem.getUri(); - } - } - - ImageView previewThumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail); - if (previewThumbnail == null) { - previewThumbnailView.setVisibility(View.GONE); - } else { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), - previewThumbnail, - 0); - } - } - - return contentPreviewLayout; - } - - private ViewGroup displayImageContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - ViewGroup parent, - ContentPreviewCoordinator previewCoord) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_image, parent, false); - ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - //TODO: addActionButton(actionRow, createCopyButton()); - addActionButton(actionRow, createNearbyButton(targetIntent)); - addActionButton(actionRow, createEditButton(targetIntent)); - - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - uri, - 0); - } else { - ContentResolver resolver = getContentResolver(); - - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - List imageUris = new ArrayList<>(); - for (Uri uri : uris) { - if (isImageType(resolver.getType(uri))) { - imageUris.add(uri); - } - } - - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - return contentPreviewLayout; - } - - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - imageUris.get(0), - 0); - - if (imageUris.size() == 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_large), - imageUris.get(1), - 0); - } else if (imageUris.size() > 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_small), - imageUris.get(1), - 0); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_3_small), - imageUris.get(2), - imageUris.size() - 3); - } - } - - return contentPreviewLayout; - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } - /** * Wrapping the ContentResolver call to expose for easier mocking, * and to avoid mocking Android core classes. @@ -1290,41 +1111,9 @@ public class ChooserActivity extends ResolverActivity implements return resolver.query(uri, null, null, null, null); } - private FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { - String fileName = null; - boolean hasThumbnail = false; - - try (Cursor cursor = queryResolver(resolver, uri)) { - if (cursor != null && cursor.getCount() > 0) { - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); - int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); - - cursor.moveToFirst(); - if (nameIndex != -1) { - fileName = cursor.getString(nameIndex); - } else if (titleIndex != -1) { - fileName = cursor.getString(titleIndex); - } - - if (flagsIndex != -1) { - hasThumbnail = (cursor.getInt(flagsIndex) - & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; - } - } - } catch (SecurityException | NullPointerException e) { - logContentPreviewWarning(uri); - } - - if (TextUtils.isEmpty(fileName)) { - fileName = uri.getPath(); - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - } - - return new FileInfo(fileName, hasThumbnail); + @VisibleForTesting + protected boolean isImageType(String mimeType) { + return mimeType != null && mimeType.startsWith("image/"); } private void logContentPreviewWarning(Uri uri) { @@ -1335,134 +1124,6 @@ public class ChooserActivity extends ResolverActivity implements + "documentation"); } - private ViewGroup displayFileContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - ViewGroup parent, - ContentPreviewCoordinator previewCoord) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_file, parent, false); - - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - //TODO(b/120417119): addActionButton(actionRow, createCopyButton()); - addActionButton(actionRow, createNearbyButton(targetIntent)); - - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout, previewCoord); - } else { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - int uriCount = uris.size(); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); - Log.i(TAG, - "Appears to be no uris available in EXTRA_STREAM, removing " - + "preview area"); - return contentPreviewLayout; - } else if (uriCount == 1) { - loadFileUriIntoView(uris.get(0), contentPreviewLayout, previewCoord); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver()); - int remUriCount = uriCount - 1; - Map arguments = new HashMap<>(); - arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); - String fileName = PluralsMessageFormatter.format( - getResources(), - arguments, - R.string.file_count); - - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - View thumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.ic_file_copy); - } - } - - return contentPreviewLayout; - } - - private void loadFileUriIntoView( - final Uri uri, final View parent, final ContentPreviewCoordinator previewCoord) { - FileInfo fileInfo = extractFileInfo(uri, getContentResolver()); - - TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); - - if (fileInfo.hasThumbnail) { - previewCoord.loadUriIntoView( - () -> parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), - uri, - 0); - } else { - View thumbnailView = parent.findViewById(com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = parent.findViewById(com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.chooser_file_generic); - } - } - - @VisibleForTesting - protected boolean isImageType(String mimeType) { - return mimeType != null && mimeType.startsWith("image/"); - } - - @ContentPreviewType - private int findPreferredContentPreview(Uri uri, ContentResolver resolver) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; - } - - String mimeType = resolver.getType(uri); - return isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; - } - - /** - * In {@link android.content.Intent#getType}, the app may specify a very general - * mime-type that broadly covers all data being shared, such as {@literal *}/* - * when sending an image and text. We therefore should inspect each item for the - * the preferred type, in order of IMAGE, FILE, TEXT. - */ - @ContentPreviewType - private int findPreferredContentPreview(Intent targetIntent, ContentResolver resolver) { - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver); - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris == null || uris.isEmpty()) { - return CONTENT_PREVIEW_TEXT; - } - - for (Uri uri : uris) { - // Defaulting to file preview when there are mixed image/file types is - // preferable, as it shows the user the correct number of items being shared - if (findPreferredContentPreview(uri, resolver) == CONTENT_PREVIEW_FILE) { - return CONTENT_PREVIEW_FILE; - } - } - - return CONTENT_PREVIEW_IMAGE; - } - - return CONTENT_PREVIEW_TEXT; - } - private int getNumSheetExpansions() { return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0); } @@ -2625,7 +2286,7 @@ public class ChooserActivity extends ResolverActivity implements ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { ViewGroup contentPreviewView = - createContentPreviewView(contentPreviewContainer, mPreviewCoord); + createContentPreviewView(contentPreviewContainer, mPreviewCoordinator); contentPreviewContainer.addView(contentPreviewView); } } @@ -2659,7 +2320,8 @@ public class ChooserActivity extends ResolverActivity implements private void logActionShareWithPreview() { Intent targetIntent = getTargetIntent(); - int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); + int previewType = ChooserContentPreviewUi.findPreferredContentPreview( + targetIntent, getContentResolver(), this::isImageType); getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW) .setSubtype(previewType)); } @@ -2982,7 +2644,7 @@ public class ChooserActivity extends ResolverActivity implements switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: return new ItemViewHolder( - createContentPreviewView(parent, mPreviewCoord), + createContentPreviewView(parent, mPreviewCoordinator), false, viewType); case VIEW_TYPE_PROFILE: diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 6d760b1a..811d5f3e 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -287,11 +287,11 @@ public class ChooserActivityLogger { */ private static int typeFromPreviewInt(int previewType) { switch(previewType) { - case ChooserActivity.CONTENT_PREVIEW_IMAGE: + case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; - case ChooserActivity.CONTENT_PREVIEW_FILE: + case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; - case ChooserActivity.CONTENT_PREVIEW_TEXT: + case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT: default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java index 509f8884..fdc58170 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -45,7 +45,8 @@ import java.util.function.Consumer; * Delegate to manage deferred resource loads for content preview assets, while * implementing Chooser's application logic for determining timeout/success/failure conditions. */ -public class ChooserContentPreviewCoordinator implements ChooserActivity.ContentPreviewCoordinator { +public class ChooserContentPreviewCoordinator implements + ChooserContentPreviewUi.ContentPreviewCoordinator { public ChooserContentPreviewCoordinator( ExecutorService backgroundExecutor, ChooserActivity chooserActivity, diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java new file mode 100644 index 00000000..22ff55db --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -0,0 +1,539 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.annotation.IntDef; +import android.content.ClipData; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Log; +import android.util.PluralsMessageFormatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.intentresolver.widget.RoundedRectImageView; +import com.android.internal.annotations.VisibleForTesting; + +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. + * + * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods + * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity} + * state other than the delegates that are explicitly provided. There may be more appropriate + * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the + * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object + * oriented" design where the static specifiers are removed and some of the dependencies are cached + * as ivars when this "class" is initialized. + */ +public final class ChooserContentPreviewUi { + /** + * Delegate to handle background resource loads that are dependencies of content previews. + */ + public interface ContentPreviewCoordinator { + /** + * Request that an image be loaded in the background and set into a view. + * + * @param viewProvider A delegate that will be called exactly once upon completion of the + * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be + * populated with the result (if the load was successful) or hidden (if the load failed). If + * this returns null, the load is discarded as a failure. + * @param imageUri The {@link Uri} of the image to load. + * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} + * if the image loads successfully. + * + * TODO: it looks like clients are probably capable of passing the view directly, but the + * deferred computation here is a closer match to the legacy model for now. + */ + void loadUriIntoView( + Callable viewProvider, Uri imageUri, int extraImages); + } + + /** + * Delegate to build the default system action buttons to display in the preview layout, if/when + * they're determined to be appropriate for the particular preview we display. + * TODO: clarify why action buttons are part of preview logic. + */ + public interface ActionButtonFactory { + /** Create a button that copies the share content to the clipboard. */ + Button createCopyButton(); + + /** Create a button that opens the share content in a system-default editor. */ + Button createEditButton(); + + /** Create a "Share to Nearby" button. */ + Button createNearbyButton(); + } + + /** + * Testing shim to specify whether a given mime type is considered to be an "image." + * + * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, + * then migrate {@link ChooserActivity#isImageType(String)} into this class. + */ + public interface ImageMimeTypeClassifier { + /** @return whether the specified {@code mimeType} is classified as an "image" type. */ + boolean isImageType(String mimeType); + } + + @Retention(SOURCE) + @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) + private @interface ContentPreviewType { + } + + // Starting at 1 since 0 is considered "undefined" for some of the database transformations + // of tron logs. + @VisibleForTesting + public static final int CONTENT_PREVIEW_IMAGE = 1; + @VisibleForTesting + public static final int CONTENT_PREVIEW_FILE = 2; + @VisibleForTesting + public static final int CONTENT_PREVIEW_TEXT = 3; + + private static final String TAG = "ChooserPreview"; + + private static final String PLURALS_COUNT = "count"; + private static final String PLURALS_FILE_NAME = "file_name"; + + /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ + @ContentPreviewType + public static int findPreferredContentPreview( + Intent targetIntent, + ContentResolver resolver, + ImageMimeTypeClassifier imageClassifier) { + /* In {@link android.content.Intent#getType}, the app may specify a very general mime type + * that broadly covers all data being shared, such as {@literal *}/* when sending an image + * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, + * FILE, TEXT. */ + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + return findPreferredContentPreview(uri, resolver, imageClassifier); + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris == null || uris.isEmpty()) { + return CONTENT_PREVIEW_TEXT; + } + + for (Uri uri : uris) { + // Defaulting to file preview when there are mixed image/file types is + // preferable, as it shows the user the correct number of items being shared + int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); + if (uriPreviewType == CONTENT_PREVIEW_FILE) { + return CONTENT_PREVIEW_FILE; + } + } + + return CONTENT_PREVIEW_IMAGE; + } + + return CONTENT_PREVIEW_TEXT; + } + + /** + * Display a content preview of the specified {@code previewType} to preview the content of the + * specified {@code intent}. + */ + public static ViewGroup displayContentPreview( + @ContentPreviewType int previewType, + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver, + ImageMimeTypeClassifier imageClassifier) { + ViewGroup layout = null; + + switch (previewType) { + case CONTENT_PREVIEW_TEXT: + layout = displayTextContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord); + break; + case CONTENT_PREVIEW_IMAGE: + layout = displayImageContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord, + contentResolver, + imageClassifier); + break; + case CONTENT_PREVIEW_FILE: + layout = displayFileContentPreview( + targetIntent, + resources, + layoutInflater, + buttonFactory, + parent, + previewCoord, + contentResolver); + break; + default: + Log.e(TAG, "Unexpected content preview type: " + previewType); + } + + return layout; + } + + private static Cursor queryResolver(ContentResolver resolver, Uri uri) { + return resolver.query(uri, null, null, null, null); + } + + @ContentPreviewType + private static int findPreferredContentPreview( + Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) { + if (uri == null) { + return CONTENT_PREVIEW_TEXT; + } + + String mimeType = resolver.getType(uri); + return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + } + + private static ViewGroup displayTextContentPreview( + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_text, parent, false); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById( + com.android.internal.R.id.chooser_action_row); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + + CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (sharingText == null) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_text_layout) + .setVisibility(View.GONE); + } else { + TextView textView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_text); + textView.setText(sharingText); + } + + String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + if (TextUtils.isEmpty(previewTitle)) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_title_layout) + .setVisibility(View.GONE); + } else { + TextView previewTitleView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_title); + previewTitleView.setText(previewTitle); + + ClipData previewData = targetIntent.getClipData(); + Uri previewThumbnail = null; + if (previewData != null) { + if (previewData.getItemCount() > 0) { + ClipData.Item previewDataItem = previewData.getItemAt(0); + previewThumbnail = previewDataItem.getUri(); + } + } + + ImageView previewThumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail); + if (previewThumbnail == null) { + previewThumbnailView.setVisibility(View.GONE); + } else { + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + previewThumbnail, + 0); + } + } + + return contentPreviewLayout; + } + + private static ViewGroup displayImageContentPreview( + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver, + ImageMimeTypeClassifier imageClassifier) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ViewGroup imagePreview = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById( + com.android.internal.R.id.chooser_action_row); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + //TODO: addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createEditButton(), iconMargin); + + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) + .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + uri, + 0); + } else { + List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + List imageUris = new ArrayList<>(); + for (Uri uri : uris) { + if (imageClassifier.isImageType(contentResolver.getType(uri))) { + imageUris.add(uri); + } + } + + if (imageUris.size() == 0) { + Log.i(TAG, "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + return contentPreviewLayout; + } + + imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) + .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + imageUris.get(0), + 0); + + if (imageUris.size() == 2) { + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_large), + imageUris.get(1), + 0); + } else if (imageUris.size() > 2) { + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_small), + imageUris.get(1), + 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_3_small), + imageUris.get(2), + imageUris.size() - 3); + } + } + + return contentPreviewLayout; + } + + private static ViewGroup displayFileContentPreview( + Intent targetIntent, + Resources resources, + LayoutInflater layoutInflater, + ActionButtonFactory buttonFactory, + ViewGroup parent, + ContentPreviewCoordinator previewCoord, + ContentResolver contentResolver) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_file, parent, false); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById( + com.android.internal.R.id.chooser_action_row); + final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); + //TODO(b/120417119): + // addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); + addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver); + } else { + List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + int uriCount = uris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, + "Appears to be no uris available in EXTRA_STREAM, removing " + + "preview area"); + return contentPreviewLayout; + } else if (uriCount == 1) { + loadFileUriIntoView( + uris.get(0), contentPreviewLayout, previewCoord, contentResolver); + } else { + FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); + int remUriCount = uriCount - 1; + Map arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = + PluralsMessageFormatter.format(resources, arguments, R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + } + + return contentPreviewLayout; + } + + private static void logContentPreviewWarning(Uri uri) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " + + "desired, consider using Intent#createChooser to launch the ChooserActivity, " + + "and set your Intent's clipData and flags in accordance with that method's " + + "documentation"); + } + + private static void loadFileUriIntoView( + final Uri uri, + final View parent, + final ContentPreviewCoordinator previewCoord, + final ContentResolver contentResolver) { + FileInfo fileInfo = extractFileInfo(uri, contentResolver); + + TextView fileNameView = parent.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileInfo.name); + + if (fileInfo.hasThumbnail) { + previewCoord.loadUriIntoView( + () -> parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + uri, + 0); + } else { + View thumbnailView = parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = parent.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.chooser_file_generic); + } + } + + private static void addActionButton(ViewGroup parent, Button b, int iconMargin) { + if (b == null) { + return; + } + final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ); + final int gap = iconMargin / 2; + lp.setMarginsRelative(gap, 0, gap, 0); + parent.addView(b, lp); + } + + private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { + String fileName = null; + boolean hasThumbnail = false; + + try (Cursor cursor = queryResolver(resolver, uri)) { + if (cursor != null && cursor.getCount() > 0) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); + int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); + + cursor.moveToFirst(); + if (nameIndex != -1) { + fileName = cursor.getString(nameIndex); + } else if (titleIndex != -1) { + fileName = cursor.getString(titleIndex); + } + + if (flagsIndex != -1) { + hasThumbnail = (cursor.getInt(flagsIndex) + & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; + } + } + } catch (SecurityException | NullPointerException e) { + logContentPreviewWarning(uri); + } + + if (TextUtils.isEmpty(fileName)) { + fileName = uri.getPath(); + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + } + + return new FileInfo(fileName, hasThumbnail); + } + + private static class FileInfo { + public final String name; + public final boolean hasThumbnail; + + FileInfo(String name, boolean hasThumbnail) { + this.name = name; + this.hasThumbnail = hasThumbnail; + } + } + + private ChooserContentPreviewUi() {} +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index a93718fd..702e725a 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -74,7 +74,7 @@ public final class ChooserActivityLoggerTest { final int appProvidedDirectTargets = 123; final int appProvidedAppTargets = 456; final boolean workProfile = true; - final int previewType = ChooserActivity.CONTENT_PREVIEW_FILE; + final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE; final String intentAction = Intent.ACTION_SENDTO; mChooserLogger.logShareStarted( -- cgit v1.2.3-59-g8ed1b From 9f9dceaa1398c925b233bcc54fb47cf3531a88da Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 18 Oct 2022 22:59:35 -0700 Subject: Extract shortcuts loading logic from ChooserActivity Extract shortcut loading logic from ChooserActivity into a new class mostly as-is. Major changes: - run the logic on a background executor and deliver the result on the main thread; - replace dependencies from ChooserListAdapter with the data it provided. A number of tests thap previusly used ChooserListAdapter#addServiceResults to provide shortcut results into the view are updated and re-enabled. Test: manual tests Test: atest IntentResolverUnitTests Change-Id: Ia81cf9d0b0415fda1cfb53d45737a72f3fe540ff --- .../android/intentresolver/ChooserActivity.java | 338 +++--------- .../ShortcutToChooserTargetConverter.java | 109 ---- .../intentresolver/shortcuts/ShortcutLoader.java | 426 +++++++++++++++ .../ShortcutToChooserTargetConverter.java | 109 ++++ java/tests/AndroidManifest.xml | 2 +- .../ChooserActivityOverrideData.java | 19 +- .../intentresolver/ChooserWrapperActivity.java | 53 +- .../android/intentresolver/IChooserWrapper.java | 3 + .../ShortcutToChooserTargetConverterTest.kt | 175 ------- .../com/android/intentresolver/TestApplication.kt | 27 + .../UnbundledChooserActivityTest.java | 574 +++++++++++---------- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 329 ++++++++++++ .../ShortcutToChooserTargetConverterTest.kt | 177 +++++++ 13 files changed, 1456 insertions(+), 885 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java create mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java create mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java delete mode 100644 java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestApplication.kt create mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt create mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index d954104e..40f04650 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -47,12 +47,10 @@ import android.content.IntentSender; import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; @@ -62,7 +60,6 @@ import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.metrics.LogMaker; import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; @@ -117,6 +114,7 @@ import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -145,6 +143,7 @@ import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -196,8 +195,8 @@ public class ChooserActivity extends ResolverActivity implements // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their // intermediate data, and then these members can be removed. - private Map mDirectShareAppTargetCache; - private Map mDirectShareShortcutInfoCache; + private final Map mDirectShareAppTargetCache = new HashMap<>(); + private final Map mDirectShareShortcutInfoCache = new HashMap<>(); public static final int TARGET_TYPE_DEFAULT = 0; public static final int TARGET_TYPE_CHOOSER_TARGET = 1; @@ -298,8 +297,6 @@ public class ChooserActivity extends ResolverActivity implements private View mContentView = null; - private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = - new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); /** @@ -475,11 +472,13 @@ public class ChooserActivity extends ResolverActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); + IntentFilter targetIntentFilter = getTargetIntentFilter(target); createProfileRecords( new AppPredictorFactory( - this, + getApplicationContext(), target.getStringExtra(Intent.EXTRA_TEXT), - getTargetIntentFilter(target))); + targetIntentFilter), + targetIntentFilter); mPreviewCoord = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, @@ -539,7 +538,6 @@ public class ChooserActivity extends ResolverActivity implements findPreferredContentPreview(getTargetIntent(), getContentResolver()), target.getAction() ); - mDirectShareShortcutInfoCache = new HashMap<>(); setEnterSharedElementCallback(new SharedElementCallback() { @Override @@ -560,20 +558,31 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - private void createProfileRecords(AppPredictorFactory factory) { + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); - createProfileRecord(mainUserHandle, factory); + createProfileRecord(mainUserHandle, targetIntentFilter, factory); UserHandle workUserHandle = getWorkProfileUserHandle(); if (workUserHandle != null) { - createProfileRecord(workUserHandle, factory); + createProfileRecord(workUserHandle, targetIntentFilter, factory); } } - private void createProfileRecord(UserHandle userHandle, AppPredictorFactory factory) { + private void createProfileRecord( + UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { AppPredictor appPredictor = factory.create(userHandle); + ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() + ? null + : createShortcutLoader( + getApplicationContext(), + appPredictor, + userHandle, + targetIntentFilter, + shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); mProfileRecords.put( - userHandle.getIdentifier(), new ProfileRecord(appPredictor)); + userHandle.getIdentifier(), + new ProfileRecord(appPredictor, shortcutLoader)); } @Nullable @@ -581,50 +590,19 @@ public class ChooserActivity extends ResolverActivity implements return mProfileRecords.get(userHandle.getIdentifier(), null); } - private void setupAppPredictorForUser( - UserHandle userHandle, AppPredictor.Callback appPredictorCallback) { - AppPredictor appPredictor = getAppPredictor(userHandle); - if (appPredictor == null) { - return; - } - mDirectShareAppTargetCache = new HashMap<>(); - appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); - } - - private AppPredictor.Callback createAppPredictorCallback( - ChooserListAdapter chooserListAdapter) { - return resultList -> { - if (isFinishing() || isDestroyed()) { - return; - } - if (chooserListAdapter.getCount() == 0) { - return; - } - if (resultList.isEmpty() - && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(chooserListAdapter, true); - return; - } - final List shareShortcutInfos = - new ArrayList<>(); - - List shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : resultList) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - resultList = shortcutResults; - for (AppTarget appTarget : resultList) { - shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName( - appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList); - }; + @VisibleForTesting + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + return new ShortcutLoader( + context, + appPredictor, + userHandle, + targetIntentFilter, + callback); } static SharedPreferences getPinnedSharedPrefs(Context context) { @@ -1821,147 +1799,6 @@ public class ChooserActivity extends ResolverActivity implements } } - @VisibleForTesting - protected void queryDirectShareTargets( - ChooserListAdapter adapter, boolean skipAppPredictionService) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record == null) { - return; - } - - record.loadingStartTime = SystemClock.elapsedRealtime(); - - UserHandle userHandle = adapter.getUserHandle(); - if (!skipAppPredictionService) { - if (record.appPredictor != null) { - record.appPredictor.requestPredictionUpdate(); - return; - } - } - // Default to just querying ShortcutManager if AppPredictor not present. - final IntentFilter filter = getTargetIntentFilter(); - if (filter == null) { - return; - } - - AsyncTask.execute(() -> { - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List resultList = sm.getShareTargets(filter); - sendShareShortcutInfoList(resultList, adapter, null); - }); - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryShortcutManager(UserHandle userHandle) { - if (!shouldShowTabs()) { - return true; - } - if (!getWorkProfileUserHandle().equals(userHandle)) { - return true; - } - if (!isUserRunning(userHandle)) { - return false; - } - if (!isUserUnlocked(userHandle)) { - return false; - } - if (isQuietModeEnabled(userHandle)) { - return false; - } - return true; - } - - private void sendShareShortcutInfoList( - List resultList, - ChooserListAdapter chooserListAdapter, - @Nullable List appTargets) { - if (appTargets != null && appTargets.size() != resultList.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + resultList.size() - + " appTargets.size()=" + appTargets.size()); - } - UserHandle userHandle = chooserListAdapter.getUserHandle(); - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - for (int i = resultList.size() - 1; i >= 0; i--) { - final String packageName = resultList.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - resultList.remove(i); - if (appTargets != null) { - appTargets.remove(i); - } - } - } - - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER : - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - - // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path - // for direct share targets. After ShareSheet is refactored we should use the - // ShareShortcutInfos directly. - List resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : chooserListAdapter.getDisplayResolveInfos()) { - List matchingShortcuts = - filterShortcutsByTargetComponentName( - resultList, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List chooserTargets = mShortcutToChooserTargetConverter - .convertToChooserTarget( - matchingShortcuts, - resultList, - appTargets, - mDirectShareAppTargetCache, - mDirectShareShortcutInfoCache); - - ServiceResultInfo resultRecord = new ServiceResultInfo( - displayResolveInfo, chooserTargets); - resultRecords.add(resultRecord); - } - - runOnUiThread(() -> { - if (!isDestroyed()) { - onShortcutsLoaded(chooserListAdapter, shortcutType, resultRecords); - } - }); - } - - private List filterShortcutsByTargetComponentName( - List allShortcuts, ComponentName requiredTarget) { - List matchingShortcuts = new ArrayList<>(); - for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { - if (requiredTarget.equals(shortcut.getTargetComponent())) { - matchingShortcuts.add(shortcut); - } - } - return matchingShortcuts; - } - - private boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - return false; - } - - if (appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) { - return true; - } - return false; - } - private void logDirectShareTargetReceived(int logCategory, UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { @@ -1995,7 +1832,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); } } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo"); + Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); } } mIsSuccessfullySelected = true; @@ -2040,9 +1877,6 @@ public class ChooserActivity extends ResolverActivity implements if (directShareAppPredictor == null) { return; } - if (!targetInfo.isChooserTargetInfo()) { - return; - } AppTarget appTarget = targetInfo.getDirectShareAppTarget(); if (appTarget != null) { // This is a direct share click that was provided by the APS @@ -2159,11 +1993,6 @@ public class ChooserActivity extends ResolverActivity implements ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, initialIntents, rList, filterLastUsed, createListController(userHandle)); - if (!ActivityManager.isLowRamDeviceStatic()) { - AppPredictor.Callback appPredictorCallback = - createAppPredictorCallback(chooserListAdapter); - setupAppPredictorForUser(userHandle, appPredictorCallback); - } return new ChooserGridAdapter(chooserListAdapter); } @@ -2450,42 +2279,41 @@ public class ChooserActivity extends ResolverActivity implements } private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - // don't support direct share on low ram devices - if (ActivityManager.isLowRamDeviceStatic()) { + UserHandle userHandle = chooserListAdapter.getUserHandle(); + ProfileRecord record = getProfileRecord(userHandle); + if (record == null) { return; } - - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + if (record.shortcutLoader == null) { return; } - - if (DEBUG) { - Log.d(TAG, "querying direct share targets from ShortcutManager"); - } - - queryDirectShareTargets(chooserListAdapter, false); + record.loadingStartTime = SystemClock.elapsedRealtime(); + record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos()); } - @VisibleForTesting @MainThread - protected void onShortcutsLoaded( - ChooserListAdapter adapter, int targetType, List resultInfos) { - UserHandle userHandle = adapter.getUserHandle(); + private void onShortcutsLoaded( + UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { if (DEBUG) { Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); } - for (ServiceResultInfo resultInfo : resultInfos) { - if (resultInfo.resultTargets != null) { + mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); + mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); + ChooserListAdapter adapter = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); + if (adapter != null) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { adapter.addServiceResults( - resultInfo.originalTarget, - resultInfo.resultTargets, - targetType, - emptyIfNull(mDirectShareShortcutInfoCache), - emptyIfNull(mDirectShareAppTargetCache)); + resultInfo.appTarget, + resultInfo.shortcuts, + shortcutsResult.isFromAppPredictor + ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); } + adapter.completeServiceTargetLoading(); } - adapter.completeServiceTargetLoading(); logDirectShareTargetReceived( MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, @@ -2495,24 +2323,6 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetDirectLoadComplete(); } - @VisibleForTesting - protected boolean isUserRunning(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserRunning(userHandle); - } - - @VisibleForTesting - protected boolean isUserUnlocked(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserUnlocked(userHandle); - } - - @VisibleForTesting - protected boolean isQuietModeEnabled(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isQuietModeEnabled(userHandle); - } - private void setupScrollListener() { if (mResolverDrawerLayout == null) { return; @@ -3549,16 +3359,6 @@ public class ChooserActivity extends ResolverActivity implements } } - static class ServiceResultInfo { - public final DisplayResolveInfo originalTarget; - public final List resultTargets; - - ServiceResultInfo(DisplayResolveInfo ot, List rt) { - originalTarget = ot; - resultTargets = rt; - } - } - static class ChooserTargetRankingInfo { public final List scores; public final UserHandle userHandle; @@ -3734,22 +3534,28 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetProfileChanged(); } - private static Map emptyIfNull(@Nullable Map map) { - return map == null ? Collections.emptyMap() : map; - } - private static class ProfileRecord { /** The {@link AppPredictor} for this profile, if any. */ @Nullable public final AppPredictor appPredictor; - + /** + * null if we should not load shortcuts. + */ + @Nullable + public final ShortcutLoader shortcutLoader; public long loadingStartTime; - ProfileRecord(@Nullable AppPredictor appPredictor) { + private ProfileRecord( + @Nullable AppPredictor appPredictor, + @Nullable ShortcutLoader shortcutLoader) { this.appPredictor = appPredictor; + this.shortcutLoader = shortcutLoader; } public void destroy() { + if (shortcutLoader != null) { + shortcutLoader.destroy(); + } if (appPredictor != null) { appPredictor.destroy(); } diff --git a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java deleted file mode 100644 index ac4270d3..00000000 --- a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.prediction.AppTarget; -import android.content.Intent; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.Bundle; -import android.service.chooser.ChooserTarget; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; - -class ShortcutToChooserTargetConverter { - - /** - * Converts a list of ShareShortcutInfos to ChooserTargets. - * @param matchingShortcuts List of shortcuts, all from the same package, that match the current - * share intent filter. - * @param allShortcuts List of all the shortcuts from all the packages on the device that are - * returned for the current sharing action. - * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. - * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget - * instances back to original allAppTargets. - * @param directShareShortcutInfoCache An optional map to store mapping from the new - * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} - * @return A list of ChooserTargets sorted by score in descending order. - */ - @NonNull - public List convertToChooserTarget( - @NonNull List matchingShortcuts, - @NonNull List allShortcuts, - @Nullable List allAppTargets, - @Nullable Map directShareAppTargetCache, - @Nullable Map directShareShortcutInfoCache) { - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final boolean isFromAppPredictor = allAppTargets != null; - // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted - // list instead of the actual rank value when converting a rank to a score. - List scoreList = new ArrayList<>(); - if (!isFromAppPredictor) { - for (int i = 0; i < matchingShortcuts.size(); i++) { - int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); - if (!scoreList.contains(shortcutRank)) { - scoreList.add(shortcutRank); - } - } - Collections.sort(scoreList); - } - - List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); - for (int i = 0; i < matchingShortcuts.size(); i++) { - ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); - int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); - - float score; - if (isFromAppPredictor) { - // Incoming results are ordered. Create a score based on index in the original list. - score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); - } else { - // Create a score based on the rank of the shortcut. - int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); - score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); - } - - Bundle extras = new Bundle(); - extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); - - ChooserTarget chooserTarget = new ChooserTarget( - shortcutInfo.getLabel(), - null, // Icon will be loaded later if this target is selected to be shown. - score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); - - chooserTargetList.add(chooserTarget); - if (directShareAppTargetCache != null && allAppTargets != null) { - directShareAppTargetCache.put(chooserTarget, - allAppTargets.get(indexInAllShortcuts)); - } - if (directShareShortcutInfoCache != null) { - directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); - } - } - // Sort ChooserTargets by score in descending order - Comparator byScore = - (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); - Collections.sort(chooserTargetList, byScore); - return chooserTargetList; - } -} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java new file mode 100644 index 00000000..1cfa2c8d --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts; + +import android.app.ActivityManager; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.content.ComponentName; +import android.content.Context; +import android.content.IntentFilter; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.ApplicationInfoFlags; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.AsyncTask; +import android.os.UserHandle; +import android.os.UserManager; +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. + *

    + * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut + * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])}, + * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered + * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread. + *

    + *

    + * The current version does not improve on the legacy in a way that it does not guarantee that + * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an + * invocation of the callback (there are early terminations of the flow). Also, the fetched + * shortcuts would be matched against the last known input, i.e. two invocations of + * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are + * processed against the latest input. + *

    + */ +public class ShortcutLoader { + private static final String TAG = "ChooserActivity"; + + private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]); + + private final Context mContext; + @Nullable + private final AppPredictorProxy mAppPredictor; + private final UserHandle mUserHandle; + @Nullable + private final IntentFilter mTargetIntentFilter; + private final Executor mBackgroundExecutor; + private final Executor mCallbackExecutor; + private final boolean mIsPersonalProfile; + private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = + new ShortcutToChooserTargetConverter(); + private final UserManager mUserManager; + private final AtomicReference> mCallback = new AtomicReference<>(); + private final AtomicReference mActiveRequest = new AtomicReference<>(NO_REQUEST); + + @Nullable + private final AppPredictor.Callback mAppPredictorCallback; + + @MainThread + public ShortcutLoader( + Context context, + @Nullable AppPredictor appPredictor, + UserHandle userHandle, + @Nullable IntentFilter targetIntentFilter, + Consumer callback) { + this( + context, + appPredictor == null ? null : new AppPredictorProxy(appPredictor), + userHandle, + userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())), + targetIntentFilter, + AsyncTask.SERIAL_EXECUTOR, + context.getMainExecutor(), + callback); + } + + @VisibleForTesting + ShortcutLoader( + Context context, + @Nullable AppPredictorProxy appPredictor, + UserHandle userHandle, + boolean isPersonalProfile, + @Nullable IntentFilter targetIntentFilter, + Executor backgroundExecutor, + Executor callbackExecutor, + Consumer callback) { + mContext = context; + mAppPredictor = appPredictor; + mUserHandle = userHandle; + mTargetIntentFilter = targetIntentFilter; + mBackgroundExecutor = backgroundExecutor; + mCallbackExecutor = callbackExecutor; + mCallback.set(callback); + mIsPersonalProfile = isPersonalProfile; + mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE); + + if (mAppPredictor != null) { + mAppPredictorCallback = createAppPredictorCallback(); + mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback); + } else { + mAppPredictorCallback = null; + } + } + + /** + * Unsubscribe from app predictor if one was provided. + */ + @MainThread + public void destroy() { + if (mCallback.getAndSet(null) != null) { + if (mAppPredictor != null) { + mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback); + } + } + } + + private boolean isDestroyed() { + return mCallback.get() == null; + } + + /** + * Set new resolved targets. This will trigger shortcut loading. + * @param appTargets a collection of application targets a loaded set of shortcuts will be + * grouped against + */ + @MainThread + public void queryShortcuts(DisplayResolveInfo[] appTargets) { + if (isDestroyed()) { + return; + } + mActiveRequest.set(new Request(appTargets)); + mBackgroundExecutor.execute(this::loadShortcuts); + } + + @WorkerThread + private void loadShortcuts() { + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryDirectShareTargets()) { + return; + } + Log.d(TAG, "querying direct share targets"); + queryDirectShareTargets(false); + } + + @WorkerThread + private void queryDirectShareTargets(boolean skipAppPredictionService) { + if (isDestroyed()) { + return; + } + if (!skipAppPredictionService && mAppPredictor != null) { + mAppPredictor.requestPredictionUpdate(); + return; + } + // Default to just querying ShortcutManager if AppPredictor not present. + if (mTargetIntentFilter == null) { + return; + } + + Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); + ShortcutManager sm = (ShortcutManager) selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE); + List shortcuts = + sm.getShareTargets(mTargetIntentFilter); + sendShareShortcutInfoList(shortcuts, false, null); + } + + private AppPredictor.Callback createAppPredictorCallback() { + return appPredictorTargets -> { + if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(true); + return; + } + + final List shortcuts = new ArrayList<>(); + List shortcutResults = new ArrayList<>(); + for (AppTarget appTarget : appPredictorTargets) { + if (appTarget.getShortcutInfo() == null) { + continue; + } + shortcutResults.add(appTarget); + } + appPredictorTargets = shortcutResults; + for (AppTarget appTarget : appPredictorTargets) { + shortcuts.add(new ShortcutManager.ShareShortcutInfo( + appTarget.getShortcutInfo(), + new ComponentName(appTarget.getPackageName(), appTarget.getClassName()))); + } + sendShareShortcutInfoList(shortcuts, true, appPredictorTargets); + }; + } + + @WorkerThread + private void sendShareShortcutInfoList( + List shortcuts, + boolean isFromAppPredictor, + @Nullable List appPredictorTargets) { + if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) { + throw new RuntimeException("resultList and appTargets must have the same size." + + " resultList.size()=" + shortcuts.size() + + " appTargets.size()=" + appPredictorTargets.size()); + } + Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); + for (int i = shortcuts.size() - 1; i >= 0; i--) { + final String packageName = shortcuts.get(i).getTargetComponent().getPackageName(); + if (!isPackageEnabled(selectedProfileContext, packageName)) { + shortcuts.remove(i); + if (appPredictorTargets != null) { + appPredictorTargets.remove(i); + } + } + } + + HashMap directShareAppTargetCache = new HashMap<>(); + HashMap directShareShortcutInfoCache = new HashMap<>(); + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets; + List resultRecords = new ArrayList<>(); + for (DisplayResolveInfo displayResolveInfo : appTargets) { + List matchingShortcuts = + filterShortcutsByTargetComponentName( + shortcuts, displayResolveInfo.getResolvedComponentName()); + if (matchingShortcuts.isEmpty()) { + continue; + } + + List chooserTargets = mShortcutToChooserTargetConverter + .convertToChooserTarget( + matchingShortcuts, + shortcuts, + appPredictorTargets, + directShareAppTargetCache, + directShareShortcutInfoCache); + + ShortcutResultInfo resultRecord = + new ShortcutResultInfo(displayResolveInfo, chooserTargets); + resultRecords.add(resultRecord); + } + + postReport( + new Result( + isFromAppPredictor, + appTargets, + resultRecords.toArray(new ShortcutResultInfo[0]), + directShareAppTargetCache, + directShareShortcutInfoCache)); + } + + private void postReport(Result result) { + mCallbackExecutor.execute(() -> report(result)); + } + + @MainThread + private void report(Result result) { + Consumer callback = mCallback.get(); + if (callback != null) { + callback.accept(result); + } + } + + /** + * Returns {@code false} if {@code userHandle} is the work profile and it's either + * in quiet mode or not running. + */ + private boolean shouldQueryDirectShareTargets() { + return mIsPersonalProfile || isProfileActive(); + } + + @VisibleForTesting + protected boolean isProfileActive() { + return mUserManager.isUserRunning(mUserHandle) + && mUserManager.isUserUnlocked(mUserHandle) + && !mUserManager.isQuietModeEnabled(mUserHandle); + } + + private static boolean isPackageEnabled(Context context, String packageName) { + if (TextUtils.isEmpty(packageName)) { + return false; + } + ApplicationInfo appInfo; + try { + appInfo = context.getPackageManager().getApplicationInfo( + packageName, + ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (NameNotFoundException e) { + return false; + } + + return appInfo != null && appInfo.enabled + && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0; + } + + private static List filterShortcutsByTargetComponentName( + List allShortcuts, ComponentName requiredTarget) { + List matchingShortcuts = new ArrayList<>(); + for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { + if (requiredTarget.equals(shortcut.getTargetComponent())) { + matchingShortcuts.add(shortcut); + } + } + return matchingShortcuts; + } + + private static class Request { + public final DisplayResolveInfo[] appTargets; + + Request(DisplayResolveInfo[] targets) { + appTargets = targets; + } + } + + /** + * Resolved shortcuts with corresponding app targets. + */ + public static class Result { + public final boolean isFromAppPredictor; + /** + * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the + * shortcuts were process against. + */ + public final DisplayResolveInfo[] appTargets; + /** + * Shortcuts grouped by app target. + */ + public final ShortcutResultInfo[] shortcutsByApp; + public final Map directShareAppTargetCache; + public final Map directShareShortcutInfoCache; + + @VisibleForTesting + public Result( + boolean isFromAppPredictor, + DisplayResolveInfo[] appTargets, + ShortcutResultInfo[] shortcutsByApp, + Map directShareAppTargetCache, + Map directShareShortcutInfoCache) { + this.isFromAppPredictor = isFromAppPredictor; + this.appTargets = appTargets; + this.shortcutsByApp = shortcutsByApp; + this.directShareAppTargetCache = directShareAppTargetCache; + this.directShareShortcutInfoCache = directShareShortcutInfoCache; + } + } + + /** + * Shortcuts grouped by app. + */ + public static class ShortcutResultInfo { + public final DisplayResolveInfo appTarget; + public final List shortcuts; + + public ShortcutResultInfo(DisplayResolveInfo appTarget, List shortcuts) { + this.appTarget = appTarget; + this.shortcuts = shortcuts; + } + } + + /** + * A wrapper around AppPredictor to facilitate unit-testing. + */ + @VisibleForTesting + public static class AppPredictorProxy { + private final AppPredictor mAppPredictor; + + AppPredictorProxy(AppPredictor appPredictor) { + mAppPredictor = appPredictor; + } + + /** + * {@link AppPredictor#registerPredictionUpdates} + */ + public void registerPredictionUpdates( + Executor callbackExecutor, AppPredictor.Callback callback) { + mAppPredictor.registerPredictionUpdates(callbackExecutor, callback); + } + + /** + * {@link AppPredictor#unregisterPredictionUpdates} + */ + public void unregisterPredictionUpdates(AppPredictor.Callback callback) { + mAppPredictor.unregisterPredictionUpdates(callback); + } + + /** + * {@link AppPredictor#requestPredictionUpdate} + */ + public void requestPredictionUpdate() { + mAppPredictor.requestPredictionUpdate(); + } + } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java new file mode 100644 index 00000000..a37d6558 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.prediction.AppTarget; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutToChooserTargetConverter { + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget + * instances back to original allAppTargets. + * @param directShareShortcutInfoCache An optional map to store mapping from the new + * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} + * @return A list of ChooserTargets sorted by score in descending order. + */ + @NonNull + public List convertToChooserTarget( + @NonNull List matchingShortcuts, + @NonNull List allShortcuts, + @Nullable List allAppTargets, + @Nullable Map directShareAppTargetCache, + @Nullable Map directShareShortcutInfoCache) { + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final boolean isFromAppPredictor = allAppTargets != null; + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List scoreList = new ArrayList<>(); + if (!isFromAppPredictor) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (isFromAppPredictor) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (directShareAppTargetCache != null && allAppTargets != null) { + directShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (directShareShortcutInfoCache != null) { + directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } +} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index b220d3ea..306eccb9 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -25,7 +25,7 @@ - + diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index e474938b..dd78b69e 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -24,15 +24,17 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; -import android.util.Pair; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import java.util.List; -import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Function; +import kotlin.jvm.functions.Function2; + /** * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. * We cannot directly mock the activity created since instrumentation creates it, so instead we use @@ -51,10 +53,8 @@ public class ChooserActivityOverrideData { @SuppressWarnings("Since15") public Function createPackageManager; public Function onSafelyStartCallback; - public Function onQueryDirectShareTargets; - public BiFunction< - IChooserWrapper, ChooserListAdapter, Pair> - directShareTargets; + public Function2, ShortcutLoader> + shortcutLoaderFactory = (userHandle, callback) -> null; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; @@ -69,15 +69,11 @@ public class ChooserActivityOverrideData { public UserHandle workProfileUserHandle; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public boolean isWorkProfileUserRunning; - public boolean isWorkProfileUserUnlocked; public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; public PackageManager packageManager; public void reset() { onSafelyStartCallback = null; - onQueryDirectShareTargets = null; - directShareTargets = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; @@ -93,8 +89,6 @@ public class ChooserActivityOverrideData { workProfileUserHandle = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; - isWorkProfileUserRunning = true; - isWorkProfileUserUnlocked = true; packageManager = null; multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { @Override @@ -114,6 +108,7 @@ public class ChooserActivityOverrideData { isQuietModeEnabled = enabled; } }; + shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8c7c28bb..6b74fcd4 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -19,11 +19,13 @@ package com.android.intentresolver; import static org.mockito.Mockito.when; import android.annotation.Nullable; +import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; @@ -31,7 +33,6 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.util.Pair; import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter; @@ -44,11 +45,12 @@ import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; /** * Simple wrapper around chooser activity to be able to initiate it under test. For more @@ -256,41 +258,18 @@ public class ChooserWrapperActivity } @Override - protected void queryDirectShareTargets( - ChooserListAdapter adapter, boolean skipAppPredictionService) { - if (sOverrides.directShareTargets != null) { - Pair result = - sOverrides.directShareTargets.apply(this, adapter); - // Imitate asynchronous shortcut loading - getMainExecutor().execute( - () -> onShortcutsLoaded( - adapter, result.first, Arrays.asList(result.second))); - return; - } - if (sOverrides.onQueryDirectShareTargets != null) { - sOverrides.onQueryDirectShareTargets.apply(adapter); - } - super.queryDirectShareTargets(adapter, skipAppPredictionService); - } - - @Override - protected boolean isQuietModeEnabled(UserHandle userHandle) { - return sOverrides.isQuietModeEnabled; - } - - @Override - protected boolean isUserRunning(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserRunning(userHandle); - } - return sOverrides.isWorkProfileUserRunning; - } - - @Override - protected boolean isUserUnlocked(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserUnlocked(userHandle); + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + ShortcutLoader shortcutLoader = + sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); + if (shortcutLoader != null) { + return shortcutLoader; } - return sOverrides.isWorkProfileUserUnlocked; + return super.createShortcutLoader( + context, appPredictor, userHandle, targetIntentFilter, callback); } } diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index f81cd023..0d44e147 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -25,6 +25,8 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; +import java.util.concurrent.Executor; + /** * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in * order to expose the internals for override/inspection. Implementations should apply the overrides @@ -41,4 +43,5 @@ public interface IChooserWrapper { @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); ChooserActivityLogger getChooserActivityLogger(); + Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt deleted file mode 100644 index 5529e714..00000000 --- a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver - -import android.app.prediction.AppTarget -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.service.chooser.ChooserTarget -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test - -private const val PACKAGE = "org.package" - -class ShortcutToChooserTargetConverterTest { - private val testSubject = ShortcutToChooserTargetConverter() - private val ranks = arrayOf(3 ,7, 1 ,3) - private val shortcuts = ranks - .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> - val id = i + 1 - acc.add( - createShareShortcutInfo( - id = "id-$i", - componentName = ComponentName(PACKAGE, "Class$id"), - rank, - ) - ) - acc - } - - @Test - fun testConvertToChooserTarget_predictionService() { - val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } - val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) - val appTargetCache = HashMap() - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderAllShortcuts, - expectedScoreAllShortcuts, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset = shortcuts.subList(1, shortcuts.size) - val expectedOrderSubset = intArrayOf(1, 2, 3) - val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) - appTargetCache.clear() - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderSubset, - expectedScoreSubset, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - @Test - fun testConvertToChooserTarget_shortcutManager() { - val testSubject = ShortcutToChooserTargetConverter() - val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset: MutableList = java.util.ArrayList() - subset.add(shortcuts[1]) - subset.add(shortcuts[2]) - subset.add(shortcuts[3]) - val expectedOrderSubset = intArrayOf(2, 3, 1) - val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - private fun assertCorrectShortcutToChooserTargetConversion( - shortcuts: List, - chooserTargets: List, - expectedOrder: IntArray, - expectedScores: FloatArray, - ) { - assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) - for (i in chooserTargets.indices) { - val ct = chooserTargets[i] - val si = shortcuts[expectedOrder[i]].shortcutInfo - val cn = shortcuts[expectedOrder[i]].targetComponent - assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) - assertEquals(si.label, ct.title) - assertEquals(expectedScores[i], ct.score) - assertEquals(cn, ct.componentName) - } - } - - private fun assertAppTargetCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val target = cache[ct] - assertNotNull("AppTarget is missing", target) - } - } - - private fun assertShortcutInfoCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val si = cache[ct] - assertNotNull("AppTarget is missing", si) - } - } -} diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt new file mode 100644 index 00000000..849cfbab --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestApplication.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.Application +import android.content.Context +import android.os.UserHandle + +class TestApplication : Application() { + + // return the current context as a work profile doesn't really exist in these tests + override fun createContextAsUser(user: UserHandle, flags: Int): Context = this +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 7c304284..da72a749 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -38,7 +38,6 @@ import static com.android.intentresolver.MatcherUtils.first; import static com.google.common.truth.Truth.assertThat; -import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; import static org.hamcrest.CoreMatchers.allOf; @@ -83,6 +82,7 @@ import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.util.Pair; +import android.util.SparseArray; import android.view.View; import androidx.annotation.CallSuper; @@ -93,9 +93,9 @@ import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ChooserActivity.ServiceResultInfo; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -118,6 +118,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -1279,7 +1280,7 @@ public class UnbundledChooserActivityTest { } // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore + @Test public void testDirectTargetSelectionLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1298,37 +1299,55 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, ""); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, ""); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1336,23 +1355,29 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + LogMaker selectionLog = logMakerCaptor.getAllValues().get(3); + assertThat( + selectionLog.getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - String hashedName = (String) logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); - assertThat("Hash is not predictable but must be obfuscated", + String hashedName = (String) selectionLog.getTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat( + "Hash is not predictable but must be obfuscated", hashedName, is(not(name))); - assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + assertThat( + "The packages shouldn't match for app target and direct target", + selectionLog.getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), + is(-1)); } // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore + @Test public void testDirectTargetLoggingWithRankedAppTarget() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1371,38 +1396,57 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1410,18 +1454,19 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(3).getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); assertThat("The packages should match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + .getAllValues().get(3).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); } - @Test @Ignore + @Test public void testShortcutTargetWithApplyAppLimits() { // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( @@ -1445,48 +1490,64 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - List shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - wrapper.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); } - @Test @Ignore + @Test public void testShortcutTargetWithoutApplyAppLimits() { setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, @@ -1513,47 +1574,65 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - List shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 4 targets (2 apps, 2 direct)", - wrapper.getAdapter().getCount(), is(4)); - assertThat("Chooser should have exactly two selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(2)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); - assertThat("The display label must match", - wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 4 targets (2 apps, 2 direct)", + activeAdapter.getCount(), + is(4)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(2)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + assertThat( + "The display label must match", + activeAdapter.getItem(1).getDisplayLabel(), + is("testTitle1")); } @Test @@ -1948,43 +2027,59 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); - - ChooserActivityOverrideData - .getInstance() - .directShareTargets = (activity, adapter) -> { - DisplayResolveInfo displayInfo = activity.createTestDisplayResolveInfo( - sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null); - ServiceResultInfo[] results = { - new ServiceResultInfo(displayInfo, serviceTargets) }; - // TODO: consider covering the other type. - // Only 2 types are expected out of the shortcut loading logic: - // - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, if shortcuts were loaded from - // the ShortcutManager, and; - // - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, if shortcuts were loaded - // from AppPredictor. - // Ideally, our tests should cover all of them. - return new Pair<>(TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, results); + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; }; // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -2098,7 +2193,7 @@ public class UnbundledChooserActivityTest { return true; }; - mActivityRule.launchActivity(sendIntent); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); waitForIdle(); assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); @@ -2273,21 +2368,20 @@ public class UnbundledChooserActivityTest { } @Test - public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { + public void test_query_shortcut_loader_for_the_selected_tab() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(3); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; + ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); + ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); + final SparseArray shortcutLoaders = new SparseArray<>(); + shortcutLoaders.put(0, personalProfileShortcutLoader); + shortcutLoaders.put(10, workProfileShortcutLoader); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2295,118 +2389,14 @@ public class UnbundledChooserActivityTest { waitForIdle(); onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - assertFalse("Direct share targets were queried on a paused work profile", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); + verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any()); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_workUserNotRunning_workTargetsShown() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertEquals(3, wrapper.getWorkListAdapter().getCount()); - } - - @Test - public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_workUserLocked_workTargetsShown() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertEquals(3, wrapper.getWorkListAdapter().getCount()); + verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); } private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { @@ -2713,4 +2703,18 @@ public class UnbundledChooserActivityTest { .getInteger(R.integer.config_chooser_max_targets_per_row)) .thenReturn(targetsPerRow); } + + private SparseArray>> + createShortcutLoaderFactory() { + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + return shortcutLoaders; + } } diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt new file mode 100644 index 00000000..5756a0cd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts + +import android.app.prediction.AppPredictor +import android.content.ComponentName +import android.content.Context +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ApplicationInfoFlags +import android.content.pm.ShortcutManager +import android.os.UserHandle +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.intentresolver.any +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.concurrent.Executor +import java.util.function.Consumer + +@SmallTest +class ShortcutLoaderTest { + private val appInfo = ApplicationInfo().apply { + enabled = true + flags = 0 + } + private val pm = mock { + whenever(getApplicationInfo(any(), any())).thenReturn(appInfo) + } + private val context = mock { + whenever(packageManager).thenReturn(pm) + whenever(createContextAsUser(any(), anyInt())).thenReturn(this) + } + private val executor = ImmediateExecutor() + private val intentFilter = mock() + private val appPredictor = mock() + private val callback = mock>() + + @Test + fun test_app_predictor_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val matchingAppTarget = createAppTarget(matchingShortcutInfo) + val shortcuts = listOf( + matchingAppTarget, + // mismatching shortcut + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + ) + appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertTrue("An app predictor result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertEquals( + "Wrong AppTarget in the cache", + matchingAppTarget, + result.directShareAppTargetCache[shortcut] + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_shortcut_manager_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + null, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_fallback_to_shortcut_manager() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_do_not_call_services_for_not_running_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) + } + + @Test + fun test_do_not_call_services_for_locked_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) + } + + @Test + fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) + } + + @Test + fun test_call_services_for_not_running_main_profile() { + testAlwaysCallSystemForMainProfile(isUserRunning = false) + } + + @Test + fun test_call_services_for_locked_main_profile() { + testAlwaysCallSystemForMainProfile(isUserUnlocked = false) + } + + @Test + fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { + testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) + } + + private fun testDisabledWorkProfileDoNotCallSystem( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock() + val callback = mock>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + false, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf(mock())) + + verify(appPredictor, never()).requestPredictionUpdate() + } + + private fun testAlwaysCallSystemForMainProfile( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock() + val callback = mock>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf(mock())) + + verify(appPredictor, times(1)).requestPredictionUpdate() + } +} + +private class ImmediateExecutor : Executor { + override fun execute(r: Runnable) { + r.run() + } +} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt new file mode 100644 index 00000000..e0de005d --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts + +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.service.chooser.ChooserTarget +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +private const val PACKAGE = "org.package" + +class ShortcutToChooserTargetConverterTest { + private val testSubject = ShortcutToChooserTargetConverter() + private val ranks = arrayOf(3 ,7, 1 ,3) + private val shortcuts = ranks + .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> + val id = i + 1 + acc.add( + createShareShortcutInfo( + id = "id-$i", + componentName = ComponentName(PACKAGE, "Class$id"), + rank, + ) + ) + acc + } + + @Test + fun testConvertToChooserTarget_predictionService() { + val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } + val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) + val appTargetCache = HashMap() + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset = shortcuts.subList(1, shortcuts.size) + val expectedOrderSubset = intArrayOf(1, 2, 3) + val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) + appTargetCache.clear() + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + @Test + fun testConvertToChooserTarget_shortcutManager() { + val testSubject = ShortcutToChooserTargetConverter() + val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset: MutableList = java.util.ArrayList() + subset.add(shortcuts[1]) + subset.add(shortcuts[2]) + subset.add(shortcuts[3]) + val expectedOrderSubset = intArrayOf(2, 3, 1) + val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + private fun assertCorrectShortcutToChooserTargetConversion( + shortcuts: List, + chooserTargets: List, + expectedOrder: IntArray, + expectedScores: FloatArray, + ) { + assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) + for (i in chooserTargets.indices) { + val ct = chooserTargets[i] + val si = shortcuts[expectedOrder[i]].shortcutInfo + val cn = shortcuts[expectedOrder[i]].targetComponent + assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) + assertEquals(si.label, ct.title) + assertEquals(expectedScores[i], ct.score) + assertEquals(cn, ct.componentName) + } + } + + private fun assertAppTargetCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val target = cache[ct] + assertNotNull("AppTarget is missing", target) + } + } + + private fun assertShortcutInfoCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val si = cache[ct] + assertNotNull("AppTarget is missing", si) + } + } +} -- cgit v1.2.3-59-g8ed1b From 83128998e50f5bce173638b4b25c5722f8267932 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 11 Nov 2022 16:49:37 -0500 Subject: Extract ChooserActivity inner ViewHolder classes These are all related to the inner ChooserGridAdapter, which I also hope to lift out of the monolith eventually, but that's a more complicated refactoring and this CL gets some low-hanging fruit out of the way first. Most of the classes were marked `static` or could have it trivially added. I only had to (minorly) rework ItemViewHolder to inject the callbacks; IMO this is a reasonable separation of the presentation logic (in the view holder) from the application logic (injected from the ChooserActivity). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ic9707e079bb472dced270f54984e6e99bea5503a --- .../android/intentresolver/ChooserActivity.java | 354 +++------------------ .../intentresolver/grid/DirectShareViewHolder.java | 199 ++++++++++++ .../intentresolver/grid/FooterViewHolder.java | 28 ++ .../intentresolver/grid/ItemGroupViewHolder.java | 76 +++++ .../intentresolver/grid/ItemViewHolder.java | 63 ++++ .../intentresolver/grid/SingleRowViewHolder.java | 73 +++++ .../intentresolver/grid/ViewHolderBase.java | 35 ++ 7 files changed, 514 insertions(+), 314 deletions(-) create mode 100644 java/src/com/android/intentresolver/grid/DirectShareViewHolder.java create mode 100644 java/src/com/android/intentresolver/grid/FooterViewHolder.java create mode 100644 java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java create mode 100644 java/src/com/android/intentresolver/grid/ItemViewHolder.java create mode 100644 java/src/com/android/intentresolver/grid/SingleRowViewHolder.java create mode 100644 java/src/com/android/intentresolver/grid/ViewHolderBase.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index d5a0c32c..cfe9d46a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -18,8 +18,6 @@ package com.android.intentresolver; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; @@ -85,7 +83,6 @@ import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; import android.view.WindowInsets; -import android.view.animation.AccelerateInterpolator; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.DecelerateInterpolator; @@ -103,6 +100,12 @@ import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.grid.DirectShareViewHolder; +import com.android.intentresolver.grid.FooterViewHolder; +import com.android.intentresolver.grid.ItemGroupViewHolder; +import com.android.intentresolver.grid.ItemViewHolder; +import com.android.intentresolver.grid.SingleRowViewHolder; +import com.android.intentresolver.grid.ViewHolderBase; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; @@ -125,7 +128,6 @@ import java.lang.annotation.RetentionPolicy; import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -221,9 +223,9 @@ public class ChooserActivity extends ResolverActivity implements * The transition time between placeholders for direct share to a message * indicating that non are available. */ - private static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; + public static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; - private static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; + public static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; private final int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, @@ -2162,60 +2164,6 @@ public class ChooserActivity extends ResolverActivity implements return mContentView; } - abstract static class ViewHolderBase extends RecyclerView.ViewHolder { - private int mViewType; - - ViewHolderBase(View itemView, int viewType) { - super(itemView); - this.mViewType = viewType; - } - - int getViewType() { - return mViewType; - } - } - - /** - * Used to bind types of individual item including - * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL}, - * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW}, - * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE}, - * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}. - */ - final class ItemViewHolder extends ViewHolderBase { - ResolverListAdapter.ViewHolder mWrappedViewHolder; - int mListPosition = ChooserListAdapter.NO_POSITION; - - ItemViewHolder(View itemView, boolean isClickable, int viewType) { - super(itemView, viewType); - mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView); - if (isClickable) { - itemView.setOnClickListener(v -> startSelected(mListPosition, - false/* always */, true/* filterd */)); - - itemView.setOnLongClickListener(v -> { - final TargetInfo ti = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(mListPosition, /* filtered */ true); - - // This should always be the case for ItemViewHolder, check for validity - if (ti.isDisplayResolveInfo()) { - showTargetDetails(ti); - } - return true; - }); - } - } - } - - /** - * Add a footer to the list, to support scrolling behavior below the navbar. - */ - static final class FooterViewHolder extends ViewHolderBase { - FooterViewHolder(View itemView, int viewType) { - super(itemView, viewType); - } - } - /** * Intentionally override the {@link ResolverActivity} implementation as we only need that * implementation for the intent resolver case. @@ -2455,15 +2403,41 @@ public class ChooserActivity extends ResolverActivity implements case VIEW_TYPE_CONTENT_PREVIEW: return new ItemViewHolder( createContentPreviewView(parent, mPreviewCoordinator), - false, - viewType); + viewType, + null, + null); case VIEW_TYPE_PROFILE: - return new ItemViewHolder(createProfileView(parent), false, viewType); + return new ItemViewHolder( + createProfileView(parent), + viewType, + null, + null); case VIEW_TYPE_AZ_LABEL: - return new ItemViewHolder(createAzLabelView(parent), false, viewType); + return new ItemViewHolder( + createAzLabelView(parent), + viewType, + null, + null); case VIEW_TYPE_NORMAL: return new ItemViewHolder( - mChooserListAdapter.createView(parent), true, viewType); + mChooserListAdapter.createView(parent), + viewType, + selectedPosition -> startSelected( + selectedPosition, + /* always= */ false, + /* filtered= */ true), + selectedPosition -> { + final TargetInfo longPressedTargetInfo = + mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .targetInfoForPosition( + selectedPosition, /* filtered= */ true); + // ItemViewHolder contents should always be "display resolve info" + // targets, but check just to make sure. + if (longPressedTargetInfo.isDisplayResolveInfo()) { + showTargetDetails(longPressedTargetInfo); + } + }); case VIEW_TYPE_DIRECT_SHARE: case VIEW_TYPE_CALLER_AND_RANK: return createItemGroupViewHolder(viewType, parent); @@ -2656,7 +2630,7 @@ public class ChooserActivity extends ResolverActivity implements void bindItemViewHolder(int position, ItemViewHolder holder) { View v = holder.itemView; int listPosition = getListPosition(position); - holder.mListPosition = listPosition; + holder.setListPosition(listPosition); mChooserListAdapter.bindView(listPosition, v); } @@ -2773,254 +2747,6 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * Used to bind types for group of items including: - * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE}, - * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}. - */ - abstract static class ItemGroupViewHolder extends ViewHolderBase { - protected int mMeasuredRowHeight; - private int[] mItemIndices; - protected final View[] mCells; - private final int mColumnCount; - - ItemGroupViewHolder(int cellCount, View itemView, int viewType) { - super(itemView, viewType); - this.mCells = new View[cellCount]; - this.mItemIndices = new int[cellCount]; - this.mColumnCount = cellCount; - } - - abstract ViewGroup addView(int index, View v); - - abstract ViewGroup getViewGroup(); - - abstract ViewGroup getRowByIndex(int index); - - abstract ViewGroup getRow(int rowNumber); - - abstract void setViewVisibility(int i, int visibility); - - public int getColumnCount() { - return mColumnCount; - } - - public void measure() { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - getViewGroup().measure(spec, spec); - mMeasuredRowHeight = getViewGroup().getMeasuredHeight(); - } - - public int getMeasuredRowHeight() { - return mMeasuredRowHeight; - } - - public void setItemIndex(int itemIndex, int listIndex) { - mItemIndices[itemIndex] = listIndex; - } - - public int getItemIndex(int itemIndex) { - return mItemIndices[itemIndex]; - } - - public View getView(int index) { - return mCells[index]; - } - } - - static class SingleRowViewHolder extends ItemGroupViewHolder { - private final ViewGroup mRow; - - SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) { - super(cellCount, row, viewType); - - this.mRow = row; - } - - public ViewGroup getViewGroup() { - return mRow; - } - - public ViewGroup getRowByIndex(int index) { - return mRow; - } - - public ViewGroup getRow(int rowNumber) { - if (rowNumber == 0) return mRow; - return null; - } - - public ViewGroup addView(int index, View v) { - mRow.addView(v); - mCells[index] = v; - - return mRow; - } - - public void setViewVisibility(int i, int visibility) { - getView(i).setVisibility(visibility); - } - } - - static class DirectShareViewHolder extends ItemGroupViewHolder { - private final ViewGroup mParent; - private final List mRows; - private int mCellCountPerRow; - - private boolean mHideDirectShareExpansion = false; - private int mDirectShareMinHeight = 0; - private int mDirectShareCurrHeight = 0; - private int mDirectShareMaxHeight = 0; - - private final boolean[] mCellVisibility; - - private final Supplier mListAdapterSupplier; - - DirectShareViewHolder(ViewGroup parent, List rows, int cellCountPerRow, - int viewType, Supplier listAdapterSupplier) { - super(rows.size() * cellCountPerRow, parent, viewType); - - this.mParent = parent; - this.mRows = rows; - this.mCellCountPerRow = cellCountPerRow; - this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; - Arrays.fill(mCellVisibility, true); - this.mListAdapterSupplier = listAdapterSupplier; - } - - public ViewGroup addView(int index, View v) { - ViewGroup row = getRowByIndex(index); - row.addView(v); - mCells[index] = v; - - return row; - } - - public ViewGroup getViewGroup() { - return mParent; - } - - public ViewGroup getRowByIndex(int index) { - return mRows.get(index / mCellCountPerRow); - } - - public ViewGroup getRow(int rowNumber) { - return mRows.get(rowNumber); - } - - public void measure() { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - getRow(0).measure(spec, spec); - getRow(1).measure(spec, spec); - - mDirectShareMinHeight = getRow(0).getMeasuredHeight(); - mDirectShareCurrHeight = mDirectShareCurrHeight > 0 - ? mDirectShareCurrHeight : mDirectShareMinHeight; - mDirectShareMaxHeight = 2 * mDirectShareMinHeight; - } - - public int getMeasuredRowHeight() { - return mDirectShareCurrHeight; - } - - public int getMinRowHeight() { - return mDirectShareMinHeight; - } - - public void setViewVisibility(int i, int visibility) { - final View v = getView(i); - if (visibility == View.VISIBLE) { - mCellVisibility[i] = true; - v.setVisibility(visibility); - v.setAlpha(1.0f); - } else if (visibility == View.INVISIBLE && mCellVisibility[i]) { - mCellVisibility[i] = false; - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); - fadeAnim.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); - fadeAnim.addListener(new AnimatorListenerAdapter() { - public void onAnimationEnd(Animator animation) { - v.setVisibility(View.INVISIBLE); - } - }); - fadeAnim.start(); - } - } - - public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) { - // only exit early if fully collapsed, otherwise onListRebuilt() with shifting - // targets can lock us into an expanded mode - boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight; - if (notExpanded) { - if (mHideDirectShareExpansion) { - return; - } - - // only expand if we have more than maxTargetsPerRow, and delay that decision - // until they start to scroll - ChooserListAdapter adapter = mListAdapterSupplier.get(); - int validTargets = adapter.getSelectableServiceTargetCount(); - if (validTargets <= maxTargetsPerRow) { - mHideDirectShareExpansion = true; - return; - } - } - - int yDiff = (int) ((oldy - y) * DIRECT_SHARE_EXPANSION_RATE); - - int prevHeight = mDirectShareCurrHeight; - int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight); - newHeight = Math.max(newHeight, mDirectShareMinHeight); - yDiff = newHeight - prevHeight; - - updateDirectShareRowHeight(view, yDiff, newHeight); - } - - void expand(RecyclerView view) { - updateDirectShareRowHeight(view, mDirectShareMaxHeight - mDirectShareCurrHeight, - mDirectShareMaxHeight); - } - - void collapse(RecyclerView view) { - updateDirectShareRowHeight(view, mDirectShareMinHeight - mDirectShareCurrHeight, - mDirectShareMinHeight); - } - - private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) { - if (view == null || view.getChildCount() == 0 || yDiff == 0) { - return; - } - - // locate the item to expand, and offset the rows below that one - boolean foundExpansion = false; - for (int i = 0; i < view.getChildCount(); i++) { - View child = view.getChildAt(i); - - if (foundExpansion) { - child.offsetTopAndBottom(yDiff); - } else { - if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) { - int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(), - MeasureSpec.EXACTLY); - int heightSpec = MeasureSpec.makeMeasureSpec(newHeight, - MeasureSpec.EXACTLY); - child.measure(widthSpec, heightSpec); - child.getLayoutParams().height = child.getMeasuredHeight(); - child.layout(child.getLeft(), child.getTop(), child.getRight(), - child.getTop() + child.getMeasuredHeight()); - - foundExpansion = true; - } - } - } - - if (foundExpansion) { - mDirectShareCurrHeight = newHeight; - } - } - } - static class ChooserTargetRankingInfo { public final List scores; public final UserHandle userHandle; diff --git a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java new file mode 100644 index 00000000..95c61e3a --- /dev/null +++ b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import android.view.animation.AccelerateInterpolator; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.ChooserActivity; +import com.android.intentresolver.ChooserListAdapter; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; + +/** Holder for direct share targets in the {@link ChooserGridAdapter}. */ +public class DirectShareViewHolder extends ItemGroupViewHolder { + private final ViewGroup mParent; + private final List mRows; + private int mCellCountPerRow; + + private boolean mHideDirectShareExpansion = false; + private int mDirectShareMinHeight = 0; + private int mDirectShareCurrHeight = 0; + private int mDirectShareMaxHeight = 0; + + private final boolean[] mCellVisibility; + + private final Supplier mListAdapterSupplier; + + public DirectShareViewHolder( + ViewGroup parent, + List rows, + int cellCountPerRow, + int viewType, + Supplier listAdapterSupplier) { + super(rows.size() * cellCountPerRow, parent, viewType); + + this.mParent = parent; + this.mRows = rows; + this.mCellCountPerRow = cellCountPerRow; + this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; + Arrays.fill(mCellVisibility, true); + this.mListAdapterSupplier = listAdapterSupplier; + } + + public ViewGroup addView(int index, View v) { + ViewGroup row = getRowByIndex(index); + row.addView(v); + mCells[index] = v; + + return row; + } + + public ViewGroup getViewGroup() { + return mParent; + } + + public ViewGroup getRowByIndex(int index) { + return mRows.get(index / mCellCountPerRow); + } + + public ViewGroup getRow(int rowNumber) { + return mRows.get(rowNumber); + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getRow(0).measure(spec, spec); + getRow(1).measure(spec, spec); + + mDirectShareMinHeight = getRow(0).getMeasuredHeight(); + mDirectShareCurrHeight = (mDirectShareCurrHeight > 0) + ? mDirectShareCurrHeight : mDirectShareMinHeight; + mDirectShareMaxHeight = 2 * mDirectShareMinHeight; + } + + public int getMeasuredRowHeight() { + return mDirectShareCurrHeight; + } + + public int getMinRowHeight() { + return mDirectShareMinHeight; + } + + public void setViewVisibility(int i, int visibility) { + final View v = getView(i); + if (visibility == View.VISIBLE) { + mCellVisibility[i] = true; + v.setVisibility(visibility); + v.setAlpha(1.0f); + } else if (visibility == View.INVISIBLE && mCellVisibility[i]) { + mCellVisibility[i] = false; + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); + fadeAnim.setDuration(ChooserActivity.NO_DIRECT_SHARE_ANIM_IN_MILLIS); + fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); + fadeAnim.addListener(new AnimatorListenerAdapter() { + public void onAnimationEnd(Animator animation) { + v.setVisibility(View.INVISIBLE); + } + }); + fadeAnim.start(); + } + } + + public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) { + // only exit early if fully collapsed, otherwise onListRebuilt() with shifting + // targets can lock us into an expanded mode + boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight; + if (notExpanded) { + if (mHideDirectShareExpansion) { + return; + } + + // only expand if we have more than maxTargetsPerRow, and delay that decision + // until they start to scroll + ChooserListAdapter adapter = mListAdapterSupplier.get(); + int validTargets = adapter.getSelectableServiceTargetCount(); + if (validTargets <= maxTargetsPerRow) { + mHideDirectShareExpansion = true; + return; + } + } + + int yDiff = (int) ((oldy - y) * ChooserActivity.DIRECT_SHARE_EXPANSION_RATE); + + int prevHeight = mDirectShareCurrHeight; + int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight); + newHeight = Math.max(newHeight, mDirectShareMinHeight); + yDiff = newHeight - prevHeight; + + updateDirectShareRowHeight(view, yDiff, newHeight); + } + + public void expand(RecyclerView view) { + updateDirectShareRowHeight( + view, mDirectShareMaxHeight - mDirectShareCurrHeight, mDirectShareMaxHeight); + } + + public void collapse(RecyclerView view) { + updateDirectShareRowHeight( + view, mDirectShareMinHeight - mDirectShareCurrHeight, mDirectShareMinHeight); + } + + private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) { + if (view == null || view.getChildCount() == 0 || yDiff == 0) { + return; + } + + // locate the item to expand, and offset the rows below that one + boolean foundExpansion = false; + for (int i = 0; i < view.getChildCount(); i++) { + View child = view.getChildAt(i); + + if (foundExpansion) { + child.offsetTopAndBottom(yDiff); + } else { + if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) { + int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(), + MeasureSpec.EXACTLY); + int heightSpec = MeasureSpec.makeMeasureSpec(newHeight, + MeasureSpec.EXACTLY); + child.measure(widthSpec, heightSpec); + child.getLayoutParams().height = child.getMeasuredHeight(); + child.layout(child.getLeft(), child.getTop(), child.getRight(), + child.getTop() + child.getMeasuredHeight()); + + foundExpansion = true; + } + } + } + + if (foundExpansion) { + mDirectShareCurrHeight = newHeight; + } + } +} diff --git a/java/src/com/android/intentresolver/grid/FooterViewHolder.java b/java/src/com/android/intentresolver/grid/FooterViewHolder.java new file mode 100644 index 00000000..0c94e3ed --- /dev/null +++ b/java/src/com/android/intentresolver/grid/FooterViewHolder.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.view.View; + +/** + * A footer on the list, to support scrolling behavior below the navbar. + */ +public final class FooterViewHolder extends ViewHolderBase { + public FooterViewHolder(View itemView, int viewType) { + super(itemView, viewType); + } +} diff --git a/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java new file mode 100644 index 00000000..5470506b --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; + +/** + * Used to bind types for group of items including: + * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}. + */ +public abstract class ItemGroupViewHolder extends ViewHolderBase { + protected int mMeasuredRowHeight; + private int[] mItemIndices; + protected final View[] mCells; + private final int mColumnCount; + + public ItemGroupViewHolder(int cellCount, View itemView, int viewType) { + super(itemView, viewType); + this.mCells = new View[cellCount]; + this.mItemIndices = new int[cellCount]; + this.mColumnCount = cellCount; + } + + public abstract ViewGroup addView(int index, View v); + + public abstract ViewGroup getViewGroup(); + + public abstract ViewGroup getRowByIndex(int index); + + public abstract ViewGroup getRow(int rowNumber); + + public abstract void setViewVisibility(int i, int visibility); + + public int getColumnCount() { + return mColumnCount; + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getViewGroup().measure(spec, spec); + mMeasuredRowHeight = getViewGroup().getMeasuredHeight(); + } + + public int getMeasuredRowHeight() { + return mMeasuredRowHeight; + } + + public void setItemIndex(int itemIndex, int listIndex) { + mItemIndices[itemIndex] = listIndex; + } + + public int getItemIndex(int itemIndex) { + return mItemIndices[itemIndex]; + } + + public View getView(int index) { + return mCells[index]; + } +} diff --git a/java/src/com/android/intentresolver/grid/ItemViewHolder.java b/java/src/com/android/intentresolver/grid/ItemViewHolder.java new file mode 100644 index 00000000..2ec56b1b --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ItemViewHolder.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.view.View; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ResolverListAdapter; + +import java.util.function.Consumer; + +/** + * Used to bind types of individual item including + * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL}, + * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW}, + * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}. + */ +public final class ItemViewHolder extends ViewHolderBase { + private final ResolverListAdapter.ViewHolder mWrappedViewHolder; + + private int mListPosition = ChooserListAdapter.NO_POSITION; + + public ItemViewHolder( + View itemView, + int viewType, + @Nullable Consumer onClick, + @Nullable Consumer onLongClick) { + super(itemView, viewType); + mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView); + + if (onClick != null) { + itemView.setOnClickListener(v -> onClick.accept(mListPosition)); + } + + if (onLongClick != null) { + itemView.setOnLongClickListener(v -> { + onLongClick.accept(mListPosition); + return true; + }); + } + } + + public void setListPosition(int listPosition) { + mListPosition = listPosition; + } +} diff --git a/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java new file mode 100644 index 00000000..a72da7aa --- /dev/null +++ b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.view.View; +import android.view.ViewGroup; + +/** Holder for a group of items displayed in a single row of the {@link ChooserGridAdapter}. */ +public final class SingleRowViewHolder extends ItemGroupViewHolder { + private final ViewGroup mRow; + + public SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) { + super(cellCount, row, viewType); + + this.mRow = row; + } + + /** Get the group of all views in this holder. */ + public ViewGroup getViewGroup() { + return mRow; + } + + /** + * Get the group of views for the row containing the specified cell index. + * TODO: unclear if that's what this `index` meant. It doesn't matter for our "single row" + * holders, and it doesn't look like this is an override from some other interface; maybe we can + * just remove? + */ + public ViewGroup getRowByIndex(int index) { + return mRow; + } + + /** Get the group of views for the specified {@code rowNumber}, if any. */ + public ViewGroup getRow(int rowNumber) { + if (rowNumber == 0) { + return mRow; + } + return null; + } + + /** + * @param index the index of the cell to add the view into. + * @param v the view to add into the cell. + */ + public ViewGroup addView(int index, View v) { + mRow.addView(v); + mCells[index] = v; + + return mRow; + } + + /** + * @param i the index of the cell containing the view to modify. + * @param visibility the new visibility to set on the view with the specified index. + */ + public void setViewVisibility(int i, int visibility) { + getView(i).setVisibility(visibility); + } +} diff --git a/java/src/com/android/intentresolver/grid/ViewHolderBase.java b/java/src/com/android/intentresolver/grid/ViewHolderBase.java new file mode 100644 index 00000000..78e9104a --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ViewHolderBase.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2022 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.intentresolver.grid; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +/** Base class for all {@link RecyclerView.ViewHolder} types in the {@link ChooserGridAdapter}. */ +public abstract class ViewHolderBase extends RecyclerView.ViewHolder { + private int mViewType; + + ViewHolderBase(View itemView, int viewType) { + super(itemView); + this.mViewType = viewType; + } + + public int getViewType() { + return mViewType; + } +} -- cgit v1.2.3-59-g8ed1b From 3ae58b63d6c8cda8ff4a224d9d0394768b71e375 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Wed, 16 Nov 2022 01:20:12 +0000 Subject: Revert "Extract shortcuts loading logic from ChooserActivity" This reverts commit 9f9dceaa1398c925b233bcc54fb47cf3531a88da. Reason for revert: DroidMonitor-triggered revert due to breakage b/259256230. Change-Id: I399625feb7ee3e6858db2863c8d66e62eccc98c4 --- .../android/intentresolver/ChooserActivity.java | 338 +++++++++--- .../ShortcutToChooserTargetConverter.java | 109 ++++ .../intentresolver/shortcuts/ShortcutLoader.java | 426 --------------- .../ShortcutToChooserTargetConverter.java | 109 ---- java/tests/AndroidManifest.xml | 2 +- .../ChooserActivityOverrideData.java | 19 +- .../intentresolver/ChooserWrapperActivity.java | 53 +- .../android/intentresolver/IChooserWrapper.java | 3 - .../ShortcutToChooserTargetConverterTest.kt | 175 +++++++ .../com/android/intentresolver/TestApplication.kt | 27 - .../UnbundledChooserActivityTest.java | 574 ++++++++++----------- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 329 ------------ .../ShortcutToChooserTargetConverterTest.kt | 177 ------- 13 files changed, 885 insertions(+), 1456 deletions(-) create mode 100644 java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java delete mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java delete mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java create mode 100644 java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt delete mode 100644 java/tests/src/com/android/intentresolver/TestApplication.kt delete mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt delete mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 40f04650..d954104e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -47,10 +47,12 @@ import android.content.IntentSender; import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; @@ -60,6 +62,7 @@ import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.metrics.LogMaker; import android.net.Uri; +import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; @@ -114,7 +117,6 @@ import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; -import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -143,7 +145,6 @@ import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -195,8 +196,8 @@ public class ChooserActivity extends ResolverActivity implements // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their // intermediate data, and then these members can be removed. - private final Map mDirectShareAppTargetCache = new HashMap<>(); - private final Map mDirectShareShortcutInfoCache = new HashMap<>(); + private Map mDirectShareAppTargetCache; + private Map mDirectShareShortcutInfoCache; public static final int TARGET_TYPE_DEFAULT = 0; public static final int TARGET_TYPE_CHOOSER_TARGET = 1; @@ -297,6 +298,8 @@ public class ChooserActivity extends ResolverActivity implements private View mContentView = null; + private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = + new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); /** @@ -472,13 +475,11 @@ public class ChooserActivity extends ResolverActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); - IntentFilter targetIntentFilter = getTargetIntentFilter(target); createProfileRecords( new AppPredictorFactory( - getApplicationContext(), + this, target.getStringExtra(Intent.EXTRA_TEXT), - targetIntentFilter), - targetIntentFilter); + getTargetIntentFilter(target))); mPreviewCoord = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, @@ -538,6 +539,7 @@ public class ChooserActivity extends ResolverActivity implements findPreferredContentPreview(getTargetIntent(), getContentResolver()), target.getAction() ); + mDirectShareShortcutInfoCache = new HashMap<>(); setEnterSharedElementCallback(new SharedElementCallback() { @Override @@ -558,31 +560,20 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - private void createProfileRecords( - AppPredictorFactory factory, IntentFilter targetIntentFilter) { + private void createProfileRecords(AppPredictorFactory factory) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); - createProfileRecord(mainUserHandle, targetIntentFilter, factory); + createProfileRecord(mainUserHandle, factory); UserHandle workUserHandle = getWorkProfileUserHandle(); if (workUserHandle != null) { - createProfileRecord(workUserHandle, targetIntentFilter, factory); + createProfileRecord(workUserHandle, factory); } } - private void createProfileRecord( - UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + private void createProfileRecord(UserHandle userHandle, AppPredictorFactory factory) { AppPredictor appPredictor = factory.create(userHandle); - ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() - ? null - : createShortcutLoader( - getApplicationContext(), - appPredictor, - userHandle, - targetIntentFilter, - shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); mProfileRecords.put( - userHandle.getIdentifier(), - new ProfileRecord(appPredictor, shortcutLoader)); + userHandle.getIdentifier(), new ProfileRecord(appPredictor)); } @Nullable @@ -590,19 +581,50 @@ public class ChooserActivity extends ResolverActivity implements return mProfileRecords.get(userHandle.getIdentifier(), null); } - @VisibleForTesting - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer callback) { - return new ShortcutLoader( - context, - appPredictor, - userHandle, - targetIntentFilter, - callback); + private void setupAppPredictorForUser( + UserHandle userHandle, AppPredictor.Callback appPredictorCallback) { + AppPredictor appPredictor = getAppPredictor(userHandle); + if (appPredictor == null) { + return; + } + mDirectShareAppTargetCache = new HashMap<>(); + appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); + } + + private AppPredictor.Callback createAppPredictorCallback( + ChooserListAdapter chooserListAdapter) { + return resultList -> { + if (isFinishing() || isDestroyed()) { + return; + } + if (chooserListAdapter.getCount() == 0) { + return; + } + if (resultList.isEmpty() + && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(chooserListAdapter, true); + return; + } + final List shareShortcutInfos = + new ArrayList<>(); + + List shortcutResults = new ArrayList<>(); + for (AppTarget appTarget : resultList) { + if (appTarget.getShortcutInfo() == null) { + continue; + } + shortcutResults.add(appTarget); + } + resultList = shortcutResults; + for (AppTarget appTarget : resultList) { + shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo( + appTarget.getShortcutInfo(), + new ComponentName( + appTarget.getPackageName(), appTarget.getClassName()))); + } + sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList); + }; } static SharedPreferences getPinnedSharedPrefs(Context context) { @@ -1799,6 +1821,147 @@ public class ChooserActivity extends ResolverActivity implements } } + @VisibleForTesting + protected void queryDirectShareTargets( + ChooserListAdapter adapter, boolean skipAppPredictionService) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + if (record == null) { + return; + } + + record.loadingStartTime = SystemClock.elapsedRealtime(); + + UserHandle userHandle = adapter.getUserHandle(); + if (!skipAppPredictionService) { + if (record.appPredictor != null) { + record.appPredictor.requestPredictionUpdate(); + return; + } + } + // Default to just querying ShortcutManager if AppPredictor not present. + final IntentFilter filter = getTargetIntentFilter(); + if (filter == null) { + return; + } + + AsyncTask.execute(() -> { + Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); + ShortcutManager sm = (ShortcutManager) selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE); + List resultList = sm.getShareTargets(filter); + sendShareShortcutInfoList(resultList, adapter, null); + }); + } + + /** + * Returns {@code false} if {@code userHandle} is the work profile and it's either + * in quiet mode or not running. + */ + private boolean shouldQueryShortcutManager(UserHandle userHandle) { + if (!shouldShowTabs()) { + return true; + } + if (!getWorkProfileUserHandle().equals(userHandle)) { + return true; + } + if (!isUserRunning(userHandle)) { + return false; + } + if (!isUserUnlocked(userHandle)) { + return false; + } + if (isQuietModeEnabled(userHandle)) { + return false; + } + return true; + } + + private void sendShareShortcutInfoList( + List resultList, + ChooserListAdapter chooserListAdapter, + @Nullable List appTargets) { + if (appTargets != null && appTargets.size() != resultList.size()) { + throw new RuntimeException("resultList and appTargets must have the same size." + + " resultList.size()=" + resultList.size() + + " appTargets.size()=" + appTargets.size()); + } + UserHandle userHandle = chooserListAdapter.getUserHandle(); + Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); + for (int i = resultList.size() - 1; i >= 0; i--) { + final String packageName = resultList.get(i).getTargetComponent().getPackageName(); + if (!isPackageEnabled(selectedProfileContext, packageName)) { + resultList.remove(i); + if (appTargets != null) { + appTargets.remove(i); + } + } + } + + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER : + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); + + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + List resultRecords = new ArrayList<>(); + for (DisplayResolveInfo displayResolveInfo : chooserListAdapter.getDisplayResolveInfos()) { + List matchingShortcuts = + filterShortcutsByTargetComponentName( + resultList, displayResolveInfo.getResolvedComponentName()); + if (matchingShortcuts.isEmpty()) { + continue; + } + + List chooserTargets = mShortcutToChooserTargetConverter + .convertToChooserTarget( + matchingShortcuts, + resultList, + appTargets, + mDirectShareAppTargetCache, + mDirectShareShortcutInfoCache); + + ServiceResultInfo resultRecord = new ServiceResultInfo( + displayResolveInfo, chooserTargets); + resultRecords.add(resultRecord); + } + + runOnUiThread(() -> { + if (!isDestroyed()) { + onShortcutsLoaded(chooserListAdapter, shortcutType, resultRecords); + } + }); + } + + private List filterShortcutsByTargetComponentName( + List allShortcuts, ComponentName requiredTarget) { + List matchingShortcuts = new ArrayList<>(); + for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { + if (requiredTarget.equals(shortcut.getTargetComponent())) { + matchingShortcuts.add(shortcut); + } + } + return matchingShortcuts; + } + + private boolean isPackageEnabled(Context context, String packageName) { + if (TextUtils.isEmpty(packageName)) { + return false; + } + ApplicationInfo appInfo; + try { + appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); + } catch (NameNotFoundException e) { + return false; + } + + if (appInfo != null && appInfo.enabled + && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) { + return true; + } + return false; + } + private void logDirectShareTargetReceived(int logCategory, UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { @@ -1832,7 +1995,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); } } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); + Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo"); } } mIsSuccessfullySelected = true; @@ -1877,6 +2040,9 @@ public class ChooserActivity extends ResolverActivity implements if (directShareAppPredictor == null) { return; } + if (!targetInfo.isChooserTargetInfo()) { + return; + } AppTarget appTarget = targetInfo.getDirectShareAppTarget(); if (appTarget != null) { // This is a direct share click that was provided by the APS @@ -1993,6 +2159,11 @@ public class ChooserActivity extends ResolverActivity implements ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, initialIntents, rList, filterLastUsed, createListController(userHandle)); + if (!ActivityManager.isLowRamDeviceStatic()) { + AppPredictor.Callback appPredictorCallback = + createAppPredictorCallback(chooserListAdapter); + setupAppPredictorForUser(userHandle, appPredictorCallback); + } return new ChooserGridAdapter(chooserListAdapter); } @@ -2279,41 +2450,42 @@ public class ChooserActivity extends ResolverActivity implements } private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - UserHandle userHandle = chooserListAdapter.getUserHandle(); - ProfileRecord record = getProfileRecord(userHandle); - if (record == null) { + // don't support direct share on low ram devices + if (ActivityManager.isLowRamDeviceStatic()) { return; } - if (record.shortcutLoader == null) { + + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { return; } - record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos()); + + if (DEBUG) { + Log.d(TAG, "querying direct share targets from ShortcutManager"); + } + + queryDirectShareTargets(chooserListAdapter, false); } + @VisibleForTesting @MainThread - private void onShortcutsLoaded( - UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { + protected void onShortcutsLoaded( + ChooserListAdapter adapter, int targetType, List resultInfos) { + UserHandle userHandle = adapter.getUserHandle(); if (DEBUG) { Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); } - mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); - mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); - ChooserListAdapter adapter = - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); - if (adapter != null) { - for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { + for (ServiceResultInfo resultInfo : resultInfos) { + if (resultInfo.resultTargets != null) { adapter.addServiceResults( - resultInfo.appTarget, - resultInfo.shortcuts, - shortcutsResult.isFromAppPredictor - ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - mDirectShareShortcutInfoCache, - mDirectShareAppTargetCache); + resultInfo.originalTarget, + resultInfo.resultTargets, + targetType, + emptyIfNull(mDirectShareShortcutInfoCache), + emptyIfNull(mDirectShareAppTargetCache)); } - adapter.completeServiceTargetLoading(); } + adapter.completeServiceTargetLoading(); logDirectShareTargetReceived( MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, @@ -2323,6 +2495,24 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetDirectLoadComplete(); } + @VisibleForTesting + protected boolean isUserRunning(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isUserRunning(userHandle); + } + + @VisibleForTesting + protected boolean isUserUnlocked(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isUserUnlocked(userHandle); + } + + @VisibleForTesting + protected boolean isQuietModeEnabled(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isQuietModeEnabled(userHandle); + } + private void setupScrollListener() { if (mResolverDrawerLayout == null) { return; @@ -3359,6 +3549,16 @@ public class ChooserActivity extends ResolverActivity implements } } + static class ServiceResultInfo { + public final DisplayResolveInfo originalTarget; + public final List resultTargets; + + ServiceResultInfo(DisplayResolveInfo ot, List rt) { + originalTarget = ot; + resultTargets = rt; + } + } + static class ChooserTargetRankingInfo { public final List scores; public final UserHandle userHandle; @@ -3534,28 +3734,22 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetProfileChanged(); } + private static Map emptyIfNull(@Nullable Map map) { + return map == null ? Collections.emptyMap() : map; + } + private static class ProfileRecord { /** The {@link AppPredictor} for this profile, if any. */ @Nullable public final AppPredictor appPredictor; - /** - * null if we should not load shortcuts. - */ - @Nullable - public final ShortcutLoader shortcutLoader; + public long loadingStartTime; - private ProfileRecord( - @Nullable AppPredictor appPredictor, - @Nullable ShortcutLoader shortcutLoader) { + ProfileRecord(@Nullable AppPredictor appPredictor) { this.appPredictor = appPredictor; - this.shortcutLoader = shortcutLoader; } public void destroy() { - if (shortcutLoader != null) { - shortcutLoader.destroy(); - } if (appPredictor != null) { appPredictor.destroy(); } diff --git a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java new file mode 100644 index 00000000..ac4270d3 --- /dev/null +++ b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.prediction.AppTarget; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutToChooserTargetConverter { + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget + * instances back to original allAppTargets. + * @param directShareShortcutInfoCache An optional map to store mapping from the new + * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} + * @return A list of ChooserTargets sorted by score in descending order. + */ + @NonNull + public List convertToChooserTarget( + @NonNull List matchingShortcuts, + @NonNull List allShortcuts, + @Nullable List allAppTargets, + @Nullable Map directShareAppTargetCache, + @Nullable Map directShareShortcutInfoCache) { + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final boolean isFromAppPredictor = allAppTargets != null; + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List scoreList = new ArrayList<>(); + if (!isFromAppPredictor) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (isFromAppPredictor) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (directShareAppTargetCache != null && allAppTargets != null) { + directShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (directShareShortcutInfoCache != null) { + directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java deleted file mode 100644 index 1cfa2c8d..00000000 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver.shortcuts; - -import android.app.ActivityManager; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.content.ComponentName; -import android.content.Context; -import android.content.IntentFilter; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.ApplicationInfoFlags; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.AsyncTask; -import android.os.UserHandle; -import android.os.UserManager; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.annotation.WorkerThread; - -import com.android.intentresolver.chooser.DisplayResolveInfo; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. - *

    - * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut - * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])}, - * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered - * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread. - *

    - *

    - * The current version does not improve on the legacy in a way that it does not guarantee that - * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an - * invocation of the callback (there are early terminations of the flow). Also, the fetched - * shortcuts would be matched against the last known input, i.e. two invocations of - * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are - * processed against the latest input. - *

    - */ -public class ShortcutLoader { - private static final String TAG = "ChooserActivity"; - - private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]); - - private final Context mContext; - @Nullable - private final AppPredictorProxy mAppPredictor; - private final UserHandle mUserHandle; - @Nullable - private final IntentFilter mTargetIntentFilter; - private final Executor mBackgroundExecutor; - private final Executor mCallbackExecutor; - private final boolean mIsPersonalProfile; - private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = - new ShortcutToChooserTargetConverter(); - private final UserManager mUserManager; - private final AtomicReference> mCallback = new AtomicReference<>(); - private final AtomicReference mActiveRequest = new AtomicReference<>(NO_REQUEST); - - @Nullable - private final AppPredictor.Callback mAppPredictorCallback; - - @MainThread - public ShortcutLoader( - Context context, - @Nullable AppPredictor appPredictor, - UserHandle userHandle, - @Nullable IntentFilter targetIntentFilter, - Consumer callback) { - this( - context, - appPredictor == null ? null : new AppPredictorProxy(appPredictor), - userHandle, - userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())), - targetIntentFilter, - AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor(), - callback); - } - - @VisibleForTesting - ShortcutLoader( - Context context, - @Nullable AppPredictorProxy appPredictor, - UserHandle userHandle, - boolean isPersonalProfile, - @Nullable IntentFilter targetIntentFilter, - Executor backgroundExecutor, - Executor callbackExecutor, - Consumer callback) { - mContext = context; - mAppPredictor = appPredictor; - mUserHandle = userHandle; - mTargetIntentFilter = targetIntentFilter; - mBackgroundExecutor = backgroundExecutor; - mCallbackExecutor = callbackExecutor; - mCallback.set(callback); - mIsPersonalProfile = isPersonalProfile; - mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE); - - if (mAppPredictor != null) { - mAppPredictorCallback = createAppPredictorCallback(); - mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback); - } else { - mAppPredictorCallback = null; - } - } - - /** - * Unsubscribe from app predictor if one was provided. - */ - @MainThread - public void destroy() { - if (mCallback.getAndSet(null) != null) { - if (mAppPredictor != null) { - mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback); - } - } - } - - private boolean isDestroyed() { - return mCallback.get() == null; - } - - /** - * Set new resolved targets. This will trigger shortcut loading. - * @param appTargets a collection of application targets a loaded set of shortcuts will be - * grouped against - */ - @MainThread - public void queryShortcuts(DisplayResolveInfo[] appTargets) { - if (isDestroyed()) { - return; - } - mActiveRequest.set(new Request(appTargets)); - mBackgroundExecutor.execute(this::loadShortcuts); - } - - @WorkerThread - private void loadShortcuts() { - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryDirectShareTargets()) { - return; - } - Log.d(TAG, "querying direct share targets"); - queryDirectShareTargets(false); - } - - @WorkerThread - private void queryDirectShareTargets(boolean skipAppPredictionService) { - if (isDestroyed()) { - return; - } - if (!skipAppPredictionService && mAppPredictor != null) { - mAppPredictor.requestPredictionUpdate(); - return; - } - // Default to just querying ShortcutManager if AppPredictor not present. - if (mTargetIntentFilter == null) { - return; - } - - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List shortcuts = - sm.getShareTargets(mTargetIntentFilter); - sendShareShortcutInfoList(shortcuts, false, null); - } - - private AppPredictor.Callback createAppPredictorCallback() { - return appPredictorTargets -> { - if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(true); - return; - } - - final List shortcuts = new ArrayList<>(); - List shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : appPredictorTargets) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - appPredictorTargets = shortcutResults; - for (AppTarget appTarget : appPredictorTargets) { - shortcuts.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName(appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shortcuts, true, appPredictorTargets); - }; - } - - @WorkerThread - private void sendShareShortcutInfoList( - List shortcuts, - boolean isFromAppPredictor, - @Nullable List appPredictorTargets) { - if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + shortcuts.size() - + " appTargets.size()=" + appPredictorTargets.size()); - } - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - for (int i = shortcuts.size() - 1; i >= 0; i--) { - final String packageName = shortcuts.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - shortcuts.remove(i); - if (appPredictorTargets != null) { - appPredictorTargets.remove(i); - } - } - } - - HashMap directShareAppTargetCache = new HashMap<>(); - HashMap directShareShortcutInfoCache = new HashMap<>(); - // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path - // for direct share targets. After ShareSheet is refactored we should use the - // ShareShortcutInfos directly. - final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets; - List resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : appTargets) { - List matchingShortcuts = - filterShortcutsByTargetComponentName( - shortcuts, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List chooserTargets = mShortcutToChooserTargetConverter - .convertToChooserTarget( - matchingShortcuts, - shortcuts, - appPredictorTargets, - directShareAppTargetCache, - directShareShortcutInfoCache); - - ShortcutResultInfo resultRecord = - new ShortcutResultInfo(displayResolveInfo, chooserTargets); - resultRecords.add(resultRecord); - } - - postReport( - new Result( - isFromAppPredictor, - appTargets, - resultRecords.toArray(new ShortcutResultInfo[0]), - directShareAppTargetCache, - directShareShortcutInfoCache)); - } - - private void postReport(Result result) { - mCallbackExecutor.execute(() -> report(result)); - } - - @MainThread - private void report(Result result) { - Consumer callback = mCallback.get(); - if (callback != null) { - callback.accept(result); - } - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryDirectShareTargets() { - return mIsPersonalProfile || isProfileActive(); - } - - @VisibleForTesting - protected boolean isProfileActive() { - return mUserManager.isUserRunning(mUserHandle) - && mUserManager.isUserUnlocked(mUserHandle) - && !mUserManager.isQuietModeEnabled(mUserHandle); - } - - private static boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo( - packageName, - ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); - } catch (NameNotFoundException e) { - return false; - } - - return appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0; - } - - private static List filterShortcutsByTargetComponentName( - List allShortcuts, ComponentName requiredTarget) { - List matchingShortcuts = new ArrayList<>(); - for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { - if (requiredTarget.equals(shortcut.getTargetComponent())) { - matchingShortcuts.add(shortcut); - } - } - return matchingShortcuts; - } - - private static class Request { - public final DisplayResolveInfo[] appTargets; - - Request(DisplayResolveInfo[] targets) { - appTargets = targets; - } - } - - /** - * Resolved shortcuts with corresponding app targets. - */ - public static class Result { - public final boolean isFromAppPredictor; - /** - * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the - * shortcuts were process against. - */ - public final DisplayResolveInfo[] appTargets; - /** - * Shortcuts grouped by app target. - */ - public final ShortcutResultInfo[] shortcutsByApp; - public final Map directShareAppTargetCache; - public final Map directShareShortcutInfoCache; - - @VisibleForTesting - public Result( - boolean isFromAppPredictor, - DisplayResolveInfo[] appTargets, - ShortcutResultInfo[] shortcutsByApp, - Map directShareAppTargetCache, - Map directShareShortcutInfoCache) { - this.isFromAppPredictor = isFromAppPredictor; - this.appTargets = appTargets; - this.shortcutsByApp = shortcutsByApp; - this.directShareAppTargetCache = directShareAppTargetCache; - this.directShareShortcutInfoCache = directShareShortcutInfoCache; - } - } - - /** - * Shortcuts grouped by app. - */ - public static class ShortcutResultInfo { - public final DisplayResolveInfo appTarget; - public final List shortcuts; - - public ShortcutResultInfo(DisplayResolveInfo appTarget, List shortcuts) { - this.appTarget = appTarget; - this.shortcuts = shortcuts; - } - } - - /** - * A wrapper around AppPredictor to facilitate unit-testing. - */ - @VisibleForTesting - public static class AppPredictorProxy { - private final AppPredictor mAppPredictor; - - AppPredictorProxy(AppPredictor appPredictor) { - mAppPredictor = appPredictor; - } - - /** - * {@link AppPredictor#registerPredictionUpdates} - */ - public void registerPredictionUpdates( - Executor callbackExecutor, AppPredictor.Callback callback) { - mAppPredictor.registerPredictionUpdates(callbackExecutor, callback); - } - - /** - * {@link AppPredictor#unregisterPredictionUpdates} - */ - public void unregisterPredictionUpdates(AppPredictor.Callback callback) { - mAppPredictor.unregisterPredictionUpdates(callback); - } - - /** - * {@link AppPredictor#requestPredictionUpdate} - */ - public void requestPredictionUpdate() { - mAppPredictor.requestPredictionUpdate(); - } - } -} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java deleted file mode 100644 index a37d6558..00000000 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver.shortcuts; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.prediction.AppTarget; -import android.content.Intent; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.Bundle; -import android.service.chooser.ChooserTarget; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; - -class ShortcutToChooserTargetConverter { - - /** - * Converts a list of ShareShortcutInfos to ChooserTargets. - * @param matchingShortcuts List of shortcuts, all from the same package, that match the current - * share intent filter. - * @param allShortcuts List of all the shortcuts from all the packages on the device that are - * returned for the current sharing action. - * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. - * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget - * instances back to original allAppTargets. - * @param directShareShortcutInfoCache An optional map to store mapping from the new - * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} - * @return A list of ChooserTargets sorted by score in descending order. - */ - @NonNull - public List convertToChooserTarget( - @NonNull List matchingShortcuts, - @NonNull List allShortcuts, - @Nullable List allAppTargets, - @Nullable Map directShareAppTargetCache, - @Nullable Map directShareShortcutInfoCache) { - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final boolean isFromAppPredictor = allAppTargets != null; - // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted - // list instead of the actual rank value when converting a rank to a score. - List scoreList = new ArrayList<>(); - if (!isFromAppPredictor) { - for (int i = 0; i < matchingShortcuts.size(); i++) { - int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); - if (!scoreList.contains(shortcutRank)) { - scoreList.add(shortcutRank); - } - } - Collections.sort(scoreList); - } - - List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); - for (int i = 0; i < matchingShortcuts.size(); i++) { - ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); - int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); - - float score; - if (isFromAppPredictor) { - // Incoming results are ordered. Create a score based on index in the original list. - score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); - } else { - // Create a score based on the rank of the shortcut. - int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); - score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); - } - - Bundle extras = new Bundle(); - extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); - - ChooserTarget chooserTarget = new ChooserTarget( - shortcutInfo.getLabel(), - null, // Icon will be loaded later if this target is selected to be shown. - score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); - - chooserTargetList.add(chooserTarget); - if (directShareAppTargetCache != null && allAppTargets != null) { - directShareAppTargetCache.put(chooserTarget, - allAppTargets.get(indexInAllShortcuts)); - } - if (directShareShortcutInfoCache != null) { - directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); - } - } - // Sort ChooserTargets by score in descending order - Comparator byScore = - (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); - Collections.sort(chooserTargetList, byScore); - return chooserTargetList; - } -} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 306eccb9..b220d3ea 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -25,7 +25,7 @@ - + diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index dd78b69e..e474938b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -24,17 +24,15 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; +import android.util.Pair; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import java.util.List; -import java.util.function.Consumer; +import java.util.function.BiFunction; import java.util.function.Function; -import kotlin.jvm.functions.Function2; - /** * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. * We cannot directly mock the activity created since instrumentation creates it, so instead we use @@ -53,8 +51,10 @@ public class ChooserActivityOverrideData { @SuppressWarnings("Since15") public Function createPackageManager; public Function onSafelyStartCallback; - public Function2, ShortcutLoader> - shortcutLoaderFactory = (userHandle, callback) -> null; + public Function onQueryDirectShareTargets; + public BiFunction< + IChooserWrapper, ChooserListAdapter, Pair> + directShareTargets; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; @@ -69,11 +69,15 @@ public class ChooserActivityOverrideData { public UserHandle workProfileUserHandle; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; + public boolean isWorkProfileUserRunning; + public boolean isWorkProfileUserUnlocked; public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; public PackageManager packageManager; public void reset() { onSafelyStartCallback = null; + onQueryDirectShareTargets = null; + directShareTargets = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; @@ -89,6 +93,8 @@ public class ChooserActivityOverrideData { workProfileUserHandle = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; + isWorkProfileUserRunning = true; + isWorkProfileUserUnlocked = true; packageManager = null; multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { @Override @@ -108,7 +114,6 @@ public class ChooserActivityOverrideData { isQuietModeEnabled = enabled; } }; - shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 6b74fcd4..8c7c28bb 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -19,13 +19,11 @@ package com.android.intentresolver; import static org.mockito.Mockito.when; import android.annotation.Nullable; -import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; @@ -33,6 +31,7 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; +import android.util.Pair; import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter; @@ -45,12 +44,11 @@ import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import java.util.Arrays; import java.util.List; -import java.util.function.Consumer; /** * Simple wrapper around chooser activity to be able to initiate it under test. For more @@ -258,18 +256,41 @@ public class ChooserWrapperActivity } @Override - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer callback) { - ShortcutLoader shortcutLoader = - sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); - if (shortcutLoader != null) { - return shortcutLoader; + protected void queryDirectShareTargets( + ChooserListAdapter adapter, boolean skipAppPredictionService) { + if (sOverrides.directShareTargets != null) { + Pair result = + sOverrides.directShareTargets.apply(this, adapter); + // Imitate asynchronous shortcut loading + getMainExecutor().execute( + () -> onShortcutsLoaded( + adapter, result.first, Arrays.asList(result.second))); + return; + } + if (sOverrides.onQueryDirectShareTargets != null) { + sOverrides.onQueryDirectShareTargets.apply(adapter); + } + super.queryDirectShareTargets(adapter, skipAppPredictionService); + } + + @Override + protected boolean isQuietModeEnabled(UserHandle userHandle) { + return sOverrides.isQuietModeEnabled; + } + + @Override + protected boolean isUserRunning(UserHandle userHandle) { + if (userHandle.equals(UserHandle.SYSTEM)) { + return super.isUserRunning(userHandle); + } + return sOverrides.isWorkProfileUserRunning; + } + + @Override + protected boolean isUserUnlocked(UserHandle userHandle) { + if (userHandle.equals(UserHandle.SYSTEM)) { + return super.isUserUnlocked(userHandle); } - return super.createShortcutLoader( - context, appPredictor, userHandle, targetIntentFilter, callback); + return sOverrides.isWorkProfileUserUnlocked; } } diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index 0d44e147..f81cd023 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -25,8 +25,6 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; -import java.util.concurrent.Executor; - /** * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in * order to expose the internals for override/inspection. Implementations should apply the overrides @@ -43,5 +41,4 @@ public interface IChooserWrapper { @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); ChooserActivityLogger getChooserActivityLogger(); - Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt new file mode 100644 index 00000000..5529e714 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.service.chooser.ChooserTarget +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +private const val PACKAGE = "org.package" + +class ShortcutToChooserTargetConverterTest { + private val testSubject = ShortcutToChooserTargetConverter() + private val ranks = arrayOf(3 ,7, 1 ,3) + private val shortcuts = ranks + .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> + val id = i + 1 + acc.add( + createShareShortcutInfo( + id = "id-$i", + componentName = ComponentName(PACKAGE, "Class$id"), + rank, + ) + ) + acc + } + + @Test + fun testConvertToChooserTarget_predictionService() { + val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } + val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) + val appTargetCache = HashMap() + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset = shortcuts.subList(1, shortcuts.size) + val expectedOrderSubset = intArrayOf(1, 2, 3) + val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) + appTargetCache.clear() + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + @Test + fun testConvertToChooserTarget_shortcutManager() { + val testSubject = ShortcutToChooserTargetConverter() + val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset: MutableList = java.util.ArrayList() + subset.add(shortcuts[1]) + subset.add(shortcuts[2]) + subset.add(shortcuts[3]) + val expectedOrderSubset = intArrayOf(2, 3, 1) + val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + private fun assertCorrectShortcutToChooserTargetConversion( + shortcuts: List, + chooserTargets: List, + expectedOrder: IntArray, + expectedScores: FloatArray, + ) { + assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) + for (i in chooserTargets.indices) { + val ct = chooserTargets[i] + val si = shortcuts[expectedOrder[i]].shortcutInfo + val cn = shortcuts[expectedOrder[i]].targetComponent + assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) + assertEquals(si.label, ct.title) + assertEquals(expectedScores[i], ct.score) + assertEquals(cn, ct.componentName) + } + } + + private fun assertAppTargetCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val target = cache[ct] + assertNotNull("AppTarget is missing", target) + } + } + + private fun assertShortcutInfoCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val si = cache[ct] + assertNotNull("AppTarget is missing", si) + } + } +} diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt deleted file mode 100644 index 849cfbab..00000000 --- a/java/tests/src/com/android/intentresolver/TestApplication.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver - -import android.app.Application -import android.content.Context -import android.os.UserHandle - -class TestApplication : Application() { - - // return the current context as a work profile doesn't really exist in these tests - override fun createContextAsUser(user: UserHandle, flags: Int): Context = this -} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index da72a749..7c304284 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -38,6 +38,7 @@ import static com.android.intentresolver.MatcherUtils.first; import static com.google.common.truth.Truth.assertThat; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; import static org.hamcrest.CoreMatchers.allOf; @@ -82,7 +83,6 @@ import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.util.Pair; -import android.util.SparseArray; import android.view.View; import androidx.annotation.CallSuper; @@ -93,9 +93,9 @@ import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; +import com.android.intentresolver.ChooserActivity.ServiceResultInfo; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -118,7 +118,6 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.function.Consumer; import java.util.function.Function; /** @@ -1280,7 +1279,7 @@ public class UnbundledChooserActivityTest { } // This test is too long and too slow and should not be taken as an example for future tests. - @Test + @Test @Ignore public void testDirectTargetSelectionLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1299,55 +1298,37 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); + // Create direct share target + List serviceTargets = createDirectShareTargets(1, ""); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, ""); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1355,29 +1336,23 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 4 invocations - // 1. ChooserActivity.logActionShareWithPreview() - // 2. ChooserActivity.onCreate() - // 3. ChooserActivity.logDirectShareTargetReceived() - // 4. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); - LogMaker selectionLog = logMakerCaptor.getAllValues().get(3); - assertThat( - selectionLog.getCategory(), + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - String hashedName = (String) selectionLog.getTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_NAME); - assertThat( - "Hash is not predictable but must be obfuscated", + String hashedName = (String) logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat("Hash is not predictable but must be obfuscated", hashedName, is(not(name))); - assertThat( - "The packages shouldn't match for app target and direct target", - selectionLog.getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), - is(-1)); + assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); } // This test is too long and too slow and should not be taken as an example for future tests. - @Test + @Test @Ignore public void testDirectTargetLoggingWithRankedAppTarget() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1396,57 +1371,38 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); + // Create direct share target + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1454,19 +1410,18 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 4 invocations - // 1. ChooserActivity.logActionShareWithPreview() - // 2. ChooserActivity.onCreate() - // 3. ChooserActivity.logDirectShareTargetReceived() - // 4. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(3).getCategory(), + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); assertThat("The packages should match for app target and direct target", logMakerCaptor - .getAllValues().get(3).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); } - @Test + @Test @Ignore public void testShortcutTargetWithApplyAppLimits() { // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( @@ -1490,64 +1445,48 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); + // Create direct share target + List serviceTargets = createDirectShareTargets(2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); // Start activity - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + List shortcutInfos = createShortcuts(activity); + directShareToShortcutInfos.put(serviceTargets.get(0), + shortcutInfos.get(0).getShortcutInfo()); + directShareToShortcutInfos.put(serviceTargets.get(1), + shortcutInfos.get(1).getShortcutInfo()); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().addServiceResults( + wrapper.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + wrapper.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat("The display label must match", + wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); } - @Test + @Test @Ignore public void testShortcutTargetWithoutApplyAppLimits() { setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, @@ -1574,65 +1513,47 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - createShortcutLoaderFactory(); + // Create direct share target + List serviceTargets = createDirectShareTargets(2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); // Start activity - final IChooserWrapper activity = (IChooserWrapper) + final ChooserActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + final IChooserWrapper wrapper = (IChooserWrapper) activity; - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() + // Insert the direct share target + Map directShareToShortcutInfos = new HashMap<>(); + List shortcutInfos = createShortcuts(activity); + directShareToShortcutInfos.put(serviceTargets.get(0), + shortcutInfos.get(0).getShortcutInfo()); + directShareToShortcutInfos.put(serviceTargets.get(1), + shortcutInfos.get(1).getShortcutInfo()); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().addServiceResults( + wrapper.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, + directShareToShortcutInfos, + /* directShareToAppTargets */ null) ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 4 targets (2 apps, 2 direct)", - activeAdapter.getCount(), - is(4)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(2)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - assertThat( - "The display label must match", - activeAdapter.getItem(1).getDisplayLabel(), - is("testTitle1")); + assertThat("Chooser should have 4 targets (2 apps, 2 direct)", + wrapper.getAdapter().getCount(), is(4)); + assertThat("Chooser should have exactly two selectable direct target", + wrapper.getAdapter().getSelectableServiceTargetCount(), is(2)); + assertThat("The resolver info must match the resolver info used to create the target", + wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat("The display label must match", + wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + assertThat("The display label must match", + wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1")); } @Test @@ -2027,59 +1948,43 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; + // Create direct share target + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + ChooserActivityOverrideData + .getInstance() + .directShareTargets = (activity, adapter) -> { + DisplayResolveInfo displayInfo = activity.createTestDisplayResolveInfo( + sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null); + ServiceResultInfo[] results = { + new ServiceResultInfo(displayInfo, serviceTargets) }; + // TODO: consider covering the other type. + // Only 2 types are expected out of the shortcut loading logic: + // - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, if shortcuts were loaded from + // the ShortcutManager, and; + // - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, if shortcuts were loaded + // from AppPredictor. + // Ideally, our tests should cover all of them. + return new Pair<>(TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, results); }; // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .queryShortcuts(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -2193,7 +2098,7 @@ public class UnbundledChooserActivityTest { return true; }; - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + mActivityRule.launchActivity(sendIntent); waitForIdle(); assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); @@ -2368,20 +2273,21 @@ public class UnbundledChooserActivityTest { } @Test - public void test_query_shortcut_loader_for_the_selected_tab() { + public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(3); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); - ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); - final SparseArray shortcutLoaders = new SparseArray<>(); - shortcutLoaders.put(0, personalProfileShortcutLoader); - shortcutLoaders.put(10, workProfileShortcutLoader); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2389,14 +2295,118 @@ public class UnbundledChooserActivityTest { waitForIdle(); onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any()); + assertFalse("Direct share targets were queried on a paused work profile", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { + markWorkProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); + assertFalse("Direct share targets were queried on a locked work profile user", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_workUserNotRunning_workTargetsShown() { + markWorkProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertEquals(3, wrapper.getWorkListAdapter().getCount()); + } + + @Test + public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { + markWorkProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertFalse("Direct share targets were queried on a locked work profile user", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_workUserLocked_workTargetsShown() { + markWorkProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); + onView(withId(com.android.internal.R.id.contentPanel)) + .perform(swipeUp()); + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + assertEquals(3, wrapper.getWorkListAdapter().getCount()); } private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { @@ -2703,18 +2713,4 @@ public class UnbundledChooserActivityTest { .getInteger(R.integer.config_chooser_max_targets_per_row)) .thenReturn(targetsPerRow); } - - private SparseArray>> - createShortcutLoaderFactory() { - SparseArray>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - return shortcutLoaders; - } } diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt deleted file mode 100644 index 5756a0cd..00000000 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver.shortcuts - -import android.app.prediction.AppPredictor -import android.content.ComponentName -import android.content.Context -import android.content.IntentFilter -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.content.pm.PackageManager.ApplicationInfoFlags -import android.content.pm.ShortcutManager -import android.os.UserHandle -import android.os.UserManager -import androidx.test.filters.SmallTest -import com.android.intentresolver.any -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.createAppTarget -import com.android.intentresolver.createShareShortcutInfo -import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.ArgumentCaptor -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import java.util.concurrent.Executor -import java.util.function.Consumer - -@SmallTest -class ShortcutLoaderTest { - private val appInfo = ApplicationInfo().apply { - enabled = true - flags = 0 - } - private val pm = mock { - whenever(getApplicationInfo(any(), any())).thenReturn(appInfo) - } - private val context = mock { - whenever(packageManager).thenReturn(pm) - whenever(createContextAsUser(any(), anyInt())).thenReturn(this) - } - private val executor = ImmediateExecutor() - private val intentFilter = mock() - private val appPredictor = mock() - private val callback = mock>() - - @Test - fun test_app_predictor_result() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) - val testSubject = ShortcutLoader( - context, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - executor, - executor, - callback - ) - - testSubject.queryShortcuts(appTargets) - - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) - - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) - val matchingAppTarget = createAppTarget(matchingShortcutInfo) - val shortcuts = listOf( - matchingAppTarget, - // mismatching shortcut - createAppTarget( - createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - ) - appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) - - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) - - val result = resultCaptor.value - assertTrue("An app predictor result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertEquals( - "Wrong AppTarget in the cache", - matchingAppTarget, - result.directShareAppTargetCache[shortcut] - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_shortcut_manager_result() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - null, - UserHandle.of(0), - true, - intentFilter, - executor, - executor, - callback - ) - - testSubject.queryShortcuts(appTargets) - - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_fallback_to_shortcut_manager() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - executor, - executor, - callback - ) - - testSubject.queryShortcuts(appTargets) - - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) - appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) - - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_do_not_call_services_for_not_running_work_profile() { - testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) - } - - @Test - fun test_do_not_call_services_for_locked_work_profile() { - testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) - } - - @Test - fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { - testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) - } - - @Test - fun test_call_services_for_not_running_main_profile() { - testAlwaysCallSystemForMainProfile(isUserRunning = false) - } - - @Test - fun test_call_services_for_locked_main_profile() { - testAlwaysCallSystemForMainProfile(isUserUnlocked = false) - } - - @Test - fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { - testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) - } - - private fun testDisabledWorkProfileDoNotCallSystem( - isUserRunning: Boolean = true, - isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - val userManager = mock { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); - val appPredictor = mock() - val callback = mock>() - val testSubject = ShortcutLoader( - context, - appPredictor, - userHandle, - false, - intentFilter, - executor, - executor, - callback - ) - - testSubject.queryShortcuts(arrayOf(mock())) - - verify(appPredictor, never()).requestPredictionUpdate() - } - - private fun testAlwaysCallSystemForMainProfile( - isUserRunning: Boolean = true, - isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - val userManager = mock { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); - val appPredictor = mock() - val callback = mock>() - val testSubject = ShortcutLoader( - context, - appPredictor, - userHandle, - true, - intentFilter, - executor, - executor, - callback - ) - - testSubject.queryShortcuts(arrayOf(mock())) - - verify(appPredictor, times(1)).requestPredictionUpdate() - } -} - -private class ImmediateExecutor : Executor { - override fun execute(r: Runnable) { - r.run() - } -} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt deleted file mode 100644 index e0de005d..00000000 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver.shortcuts - -import android.app.prediction.AppTarget -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.service.chooser.ChooserTarget -import com.android.intentresolver.createAppTarget -import com.android.intentresolver.createShareShortcutInfo -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test - -private const val PACKAGE = "org.package" - -class ShortcutToChooserTargetConverterTest { - private val testSubject = ShortcutToChooserTargetConverter() - private val ranks = arrayOf(3 ,7, 1 ,3) - private val shortcuts = ranks - .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> - val id = i + 1 - acc.add( - createShareShortcutInfo( - id = "id-$i", - componentName = ComponentName(PACKAGE, "Class$id"), - rank, - ) - ) - acc - } - - @Test - fun testConvertToChooserTarget_predictionService() { - val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } - val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) - val appTargetCache = HashMap() - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderAllShortcuts, - expectedScoreAllShortcuts, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset = shortcuts.subList(1, shortcuts.size) - val expectedOrderSubset = intArrayOf(1, 2, 3) - val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) - appTargetCache.clear() - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderSubset, - expectedScoreSubset, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - @Test - fun testConvertToChooserTarget_shortcutManager() { - val testSubject = ShortcutToChooserTargetConverter() - val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset: MutableList = java.util.ArrayList() - subset.add(shortcuts[1]) - subset.add(shortcuts[2]) - subset.add(shortcuts[3]) - val expectedOrderSubset = intArrayOf(2, 3, 1) - val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - private fun assertCorrectShortcutToChooserTargetConversion( - shortcuts: List, - chooserTargets: List, - expectedOrder: IntArray, - expectedScores: FloatArray, - ) { - assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) - for (i in chooserTargets.indices) { - val ct = chooserTargets[i] - val si = shortcuts[expectedOrder[i]].shortcutInfo - val cn = shortcuts[expectedOrder[i]].targetComponent - assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) - assertEquals(si.label, ct.title) - assertEquals(expectedScores[i], ct.score) - assertEquals(cn, ct.componentName) - } - } - - private fun assertAppTargetCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val target = cache[ct] - assertNotNull("AppTarget is missing", target) - } - } - - private fun assertShortcutInfoCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val si = cache[ct] - assertNotNull("AppTarget is missing", si) - } - } -} -- cgit v1.2.3-59-g8ed1b From 7697b5f3b4549749e55acdd930f87bcedb56b422 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 18 Oct 2022 22:59:35 -0700 Subject: Extract shortcuts loading logic from ChooserActivity Extract shortcut loading logic from ChooserActivity into a new class mostly as-is. Major changes: - run the logic on a background executor and deliver the result on the main thread; - replace dependencies from ChooserListAdapter with the data it provided. A number of tests thap previusly used ChooserListAdapter#addServiceResults to provide shortcut results into the view are updated and re-enabled. Re-introduction of ag/20236439 Fix: 259462188 Test: manual tests Test: atest IntentResolverUnitTests Change-Id: I2555c3e486b9a443c5101bbda33b5b214c959b0f --- .../android/intentresolver/ChooserActivity.java | 338 +++--------- .../ShortcutToChooserTargetConverter.java | 109 ---- .../intentresolver/shortcuts/ShortcutLoader.java | 426 +++++++++++++++ .../ShortcutToChooserTargetConverter.java | 109 ++++ java/tests/AndroidManifest.xml | 2 +- .../ChooserActivityOverrideData.java | 19 +- .../intentresolver/ChooserWrapperActivity.java | 53 +- .../android/intentresolver/IChooserWrapper.java | 3 + .../ShortcutToChooserTargetConverterTest.kt | 175 ------- .../com/android/intentresolver/TestApplication.kt | 27 + .../UnbundledChooserActivityTest.java | 574 +++++++++++---------- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 329 ++++++++++++ .../ShortcutToChooserTargetConverterTest.kt | 177 +++++++ 13 files changed, 1456 insertions(+), 885 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java create mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java create mode 100644 java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java delete mode 100644 java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestApplication.kt create mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt create mode 100644 java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 558dfcf7..d5a0c32c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -45,12 +45,10 @@ import android.content.IntentSender; import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; @@ -60,7 +58,6 @@ import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.metrics.LogMaker; import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; @@ -110,6 +107,7 @@ import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -136,6 +134,7 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -187,8 +186,8 @@ public class ChooserActivity extends ResolverActivity implements // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their // intermediate data, and then these members can be removed. - private Map mDirectShareAppTargetCache; - private Map mDirectShareShortcutInfoCache; + private final Map mDirectShareAppTargetCache = new HashMap<>(); + private final Map mDirectShareShortcutInfoCache = new HashMap<>(); public static final int TARGET_TYPE_DEFAULT = 0; public static final int TARGET_TYPE_CHOOSER_TARGET = 1; @@ -279,8 +278,6 @@ public class ChooserActivity extends ResolverActivity implements private View mContentView = null; - private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = - new ShortcutToChooserTargetConverter(); private final SparseArray mProfileRecords = new SparseArray<>(); private void setupPreDrawForSharedElementTransition(View v) { @@ -432,11 +429,13 @@ public class ChooserActivity extends ResolverActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); + IntentFilter targetIntentFilter = getTargetIntentFilter(target); createProfileRecords( new AppPredictorFactory( - this, + getApplicationContext(), target.getStringExtra(Intent.EXTRA_TEXT), - getTargetIntentFilter(target))); + targetIntentFilter), + targetIntentFilter); mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, @@ -497,7 +496,6 @@ public class ChooserActivity extends ResolverActivity implements getTargetIntent(), getContentResolver(), this::isImageType), target.getAction() ); - mDirectShareShortcutInfoCache = new HashMap<>(); setEnterSharedElementCallback(new SharedElementCallback() { @Override @@ -518,20 +516,31 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - private void createProfileRecords(AppPredictorFactory factory) { + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); - createProfileRecord(mainUserHandle, factory); + createProfileRecord(mainUserHandle, targetIntentFilter, factory); UserHandle workUserHandle = getWorkProfileUserHandle(); if (workUserHandle != null) { - createProfileRecord(workUserHandle, factory); + createProfileRecord(workUserHandle, targetIntentFilter, factory); } } - private void createProfileRecord(UserHandle userHandle, AppPredictorFactory factory) { + private void createProfileRecord( + UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { AppPredictor appPredictor = factory.create(userHandle); + ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() + ? null + : createShortcutLoader( + getApplicationContext(), + appPredictor, + userHandle, + targetIntentFilter, + shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); mProfileRecords.put( - userHandle.getIdentifier(), new ProfileRecord(appPredictor)); + userHandle.getIdentifier(), + new ProfileRecord(appPredictor, shortcutLoader)); } @Nullable @@ -539,50 +548,19 @@ public class ChooserActivity extends ResolverActivity implements return mProfileRecords.get(userHandle.getIdentifier(), null); } - private void setupAppPredictorForUser( - UserHandle userHandle, AppPredictor.Callback appPredictorCallback) { - AppPredictor appPredictor = getAppPredictor(userHandle); - if (appPredictor == null) { - return; - } - mDirectShareAppTargetCache = new HashMap<>(); - appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); - } - - private AppPredictor.Callback createAppPredictorCallback( - ChooserListAdapter chooserListAdapter) { - return resultList -> { - if (isFinishing() || isDestroyed()) { - return; - } - if (chooserListAdapter.getCount() == 0) { - return; - } - if (resultList.isEmpty() - && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(chooserListAdapter, true); - return; - } - final List shareShortcutInfos = - new ArrayList<>(); - - List shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : resultList) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - resultList = shortcutResults; - for (AppTarget appTarget : resultList) { - shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName( - appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList); - }; + @VisibleForTesting + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + return new ShortcutLoader( + context, + appPredictor, + userHandle, + targetIntentFilter, + callback); } static SharedPreferences getPinnedSharedPrefs(Context context) { @@ -1482,147 +1460,6 @@ public class ChooserActivity extends ResolverActivity implements } } - @VisibleForTesting - protected void queryDirectShareTargets( - ChooserListAdapter adapter, boolean skipAppPredictionService) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record == null) { - return; - } - - record.loadingStartTime = SystemClock.elapsedRealtime(); - - UserHandle userHandle = adapter.getUserHandle(); - if (!skipAppPredictionService) { - if (record.appPredictor != null) { - record.appPredictor.requestPredictionUpdate(); - return; - } - } - // Default to just querying ShortcutManager if AppPredictor not present. - final IntentFilter filter = getTargetIntentFilter(); - if (filter == null) { - return; - } - - AsyncTask.execute(() -> { - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List resultList = sm.getShareTargets(filter); - sendShareShortcutInfoList(resultList, adapter, null); - }); - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryShortcutManager(UserHandle userHandle) { - if (!shouldShowTabs()) { - return true; - } - if (!getWorkProfileUserHandle().equals(userHandle)) { - return true; - } - if (!isUserRunning(userHandle)) { - return false; - } - if (!isUserUnlocked(userHandle)) { - return false; - } - if (isQuietModeEnabled(userHandle)) { - return false; - } - return true; - } - - private void sendShareShortcutInfoList( - List resultList, - ChooserListAdapter chooserListAdapter, - @Nullable List appTargets) { - if (appTargets != null && appTargets.size() != resultList.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + resultList.size() - + " appTargets.size()=" + appTargets.size()); - } - UserHandle userHandle = chooserListAdapter.getUserHandle(); - Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); - for (int i = resultList.size() - 1; i >= 0; i--) { - final String packageName = resultList.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - resultList.remove(i); - if (appTargets != null) { - appTargets.remove(i); - } - } - } - - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER : - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); - - // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path - // for direct share targets. After ShareSheet is refactored we should use the - // ShareShortcutInfos directly. - List resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : chooserListAdapter.getDisplayResolveInfos()) { - List matchingShortcuts = - filterShortcutsByTargetComponentName( - resultList, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List chooserTargets = mShortcutToChooserTargetConverter - .convertToChooserTarget( - matchingShortcuts, - resultList, - appTargets, - mDirectShareAppTargetCache, - mDirectShareShortcutInfoCache); - - ServiceResultInfo resultRecord = new ServiceResultInfo( - displayResolveInfo, chooserTargets); - resultRecords.add(resultRecord); - } - - runOnUiThread(() -> { - if (!isDestroyed()) { - onShortcutsLoaded(chooserListAdapter, shortcutType, resultRecords); - } - }); - } - - private List filterShortcutsByTargetComponentName( - List allShortcuts, ComponentName requiredTarget) { - List matchingShortcuts = new ArrayList<>(); - for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { - if (requiredTarget.equals(shortcut.getTargetComponent())) { - matchingShortcuts.add(shortcut); - } - } - return matchingShortcuts; - } - - private boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - return false; - } - - if (appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) { - return true; - } - return false; - } - private void logDirectShareTargetReceived(int logCategory, UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { @@ -1656,7 +1493,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); } } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo"); + Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); } } mIsSuccessfullySelected = true; @@ -1701,9 +1538,6 @@ public class ChooserActivity extends ResolverActivity implements if (directShareAppPredictor == null) { return; } - if (!targetInfo.isChooserTargetInfo()) { - return; - } AppTarget appTarget = targetInfo.getDirectShareAppTarget(); if (appTarget != null) { // This is a direct share click that was provided by the APS @@ -1820,11 +1654,6 @@ public class ChooserActivity extends ResolverActivity implements ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, initialIntents, rList, filterLastUsed, createListController(userHandle)); - if (!ActivityManager.isLowRamDeviceStatic()) { - AppPredictor.Callback appPredictorCallback = - createAppPredictorCallback(chooserListAdapter); - setupAppPredictorForUser(userHandle, appPredictorCallback); - } return new ChooserGridAdapter(chooserListAdapter); } @@ -2111,42 +1940,41 @@ public class ChooserActivity extends ResolverActivity implements } private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - // don't support direct share on low ram devices - if (ActivityManager.isLowRamDeviceStatic()) { + UserHandle userHandle = chooserListAdapter.getUserHandle(); + ProfileRecord record = getProfileRecord(userHandle); + if (record == null) { return; } - - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + if (record.shortcutLoader == null) { return; } - - if (DEBUG) { - Log.d(TAG, "querying direct share targets from ShortcutManager"); - } - - queryDirectShareTargets(chooserListAdapter, false); + record.loadingStartTime = SystemClock.elapsedRealtime(); + record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos()); } - @VisibleForTesting @MainThread - protected void onShortcutsLoaded( - ChooserListAdapter adapter, int targetType, List resultInfos) { - UserHandle userHandle = adapter.getUserHandle(); + private void onShortcutsLoaded( + UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { if (DEBUG) { Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); } - for (ServiceResultInfo resultInfo : resultInfos) { - if (resultInfo.resultTargets != null) { + mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); + mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); + ChooserListAdapter adapter = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); + if (adapter != null) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { adapter.addServiceResults( - resultInfo.originalTarget, - resultInfo.resultTargets, - targetType, - emptyIfNull(mDirectShareShortcutInfoCache), - emptyIfNull(mDirectShareAppTargetCache)); + resultInfo.appTarget, + resultInfo.shortcuts, + shortcutsResult.isFromAppPredictor + ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + mDirectShareShortcutInfoCache, + mDirectShareAppTargetCache); } + adapter.completeServiceTargetLoading(); } - adapter.completeServiceTargetLoading(); logDirectShareTargetReceived( MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, @@ -2156,24 +1984,6 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetDirectLoadComplete(); } - @VisibleForTesting - protected boolean isUserRunning(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserRunning(userHandle); - } - - @VisibleForTesting - protected boolean isUserUnlocked(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isUserUnlocked(userHandle); - } - - @VisibleForTesting - protected boolean isQuietModeEnabled(UserHandle userHandle) { - UserManager userManager = getSystemService(UserManager.class); - return userManager.isQuietModeEnabled(userHandle); - } - private void setupScrollListener() { if (mResolverDrawerLayout == null) { return; @@ -3211,16 +3021,6 @@ public class ChooserActivity extends ResolverActivity implements } } - static class ServiceResultInfo { - public final DisplayResolveInfo originalTarget; - public final List resultTargets; - - ServiceResultInfo(DisplayResolveInfo ot, List rt) { - originalTarget = ot; - resultTargets = rt; - } - } - static class ChooserTargetRankingInfo { public final List scores; public final UserHandle userHandle; @@ -3396,22 +3196,28 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetProfileChanged(); } - private static Map emptyIfNull(@Nullable Map map) { - return map == null ? Collections.emptyMap() : map; - } - private static class ProfileRecord { /** The {@link AppPredictor} for this profile, if any. */ @Nullable public final AppPredictor appPredictor; - + /** + * null if we should not load shortcuts. + */ + @Nullable + public final ShortcutLoader shortcutLoader; public long loadingStartTime; - ProfileRecord(@Nullable AppPredictor appPredictor) { + private ProfileRecord( + @Nullable AppPredictor appPredictor, + @Nullable ShortcutLoader shortcutLoader) { this.appPredictor = appPredictor; + this.shortcutLoader = shortcutLoader; } public void destroy() { + if (shortcutLoader != null) { + shortcutLoader.destroy(); + } if (appPredictor != null) { appPredictor.destroy(); } diff --git a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java deleted file mode 100644 index ac4270d3..00000000 --- a/java/src/com/android/intentresolver/ShortcutToChooserTargetConverter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.prediction.AppTarget; -import android.content.Intent; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.Bundle; -import android.service.chooser.ChooserTarget; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; - -class ShortcutToChooserTargetConverter { - - /** - * Converts a list of ShareShortcutInfos to ChooserTargets. - * @param matchingShortcuts List of shortcuts, all from the same package, that match the current - * share intent filter. - * @param allShortcuts List of all the shortcuts from all the packages on the device that are - * returned for the current sharing action. - * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. - * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget - * instances back to original allAppTargets. - * @param directShareShortcutInfoCache An optional map to store mapping from the new - * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} - * @return A list of ChooserTargets sorted by score in descending order. - */ - @NonNull - public List convertToChooserTarget( - @NonNull List matchingShortcuts, - @NonNull List allShortcuts, - @Nullable List allAppTargets, - @Nullable Map directShareAppTargetCache, - @Nullable Map directShareShortcutInfoCache) { - // If |appTargets| is not null, results are from AppPredictionService and already sorted. - final boolean isFromAppPredictor = allAppTargets != null; - // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted - // list instead of the actual rank value when converting a rank to a score. - List scoreList = new ArrayList<>(); - if (!isFromAppPredictor) { - for (int i = 0; i < matchingShortcuts.size(); i++) { - int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); - if (!scoreList.contains(shortcutRank)) { - scoreList.add(shortcutRank); - } - } - Collections.sort(scoreList); - } - - List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); - for (int i = 0; i < matchingShortcuts.size(); i++) { - ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); - int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); - - float score; - if (isFromAppPredictor) { - // Incoming results are ordered. Create a score based on index in the original list. - score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); - } else { - // Create a score based on the rank of the shortcut. - int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); - score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); - } - - Bundle extras = new Bundle(); - extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); - - ChooserTarget chooserTarget = new ChooserTarget( - shortcutInfo.getLabel(), - null, // Icon will be loaded later if this target is selected to be shown. - score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); - - chooserTargetList.add(chooserTarget); - if (directShareAppTargetCache != null && allAppTargets != null) { - directShareAppTargetCache.put(chooserTarget, - allAppTargets.get(indexInAllShortcuts)); - } - if (directShareShortcutInfoCache != null) { - directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); - } - } - // Sort ChooserTargets by score in descending order - Comparator byScore = - (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); - Collections.sort(chooserTargetList, byScore); - return chooserTargetList; - } -} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java new file mode 100644 index 00000000..1cfa2c8d --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts; + +import android.app.ActivityManager; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.content.ComponentName; +import android.content.Context; +import android.content.IntentFilter; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.ApplicationInfoFlags; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.AsyncTask; +import android.os.UserHandle; +import android.os.UserManager; +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. + *

    + * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut + * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])}, + * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered + * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread. + *

    + *

    + * The current version does not improve on the legacy in a way that it does not guarantee that + * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an + * invocation of the callback (there are early terminations of the flow). Also, the fetched + * shortcuts would be matched against the last known input, i.e. two invocations of + * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are + * processed against the latest input. + *

    + */ +public class ShortcutLoader { + private static final String TAG = "ChooserActivity"; + + private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]); + + private final Context mContext; + @Nullable + private final AppPredictorProxy mAppPredictor; + private final UserHandle mUserHandle; + @Nullable + private final IntentFilter mTargetIntentFilter; + private final Executor mBackgroundExecutor; + private final Executor mCallbackExecutor; + private final boolean mIsPersonalProfile; + private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = + new ShortcutToChooserTargetConverter(); + private final UserManager mUserManager; + private final AtomicReference> mCallback = new AtomicReference<>(); + private final AtomicReference mActiveRequest = new AtomicReference<>(NO_REQUEST); + + @Nullable + private final AppPredictor.Callback mAppPredictorCallback; + + @MainThread + public ShortcutLoader( + Context context, + @Nullable AppPredictor appPredictor, + UserHandle userHandle, + @Nullable IntentFilter targetIntentFilter, + Consumer callback) { + this( + context, + appPredictor == null ? null : new AppPredictorProxy(appPredictor), + userHandle, + userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())), + targetIntentFilter, + AsyncTask.SERIAL_EXECUTOR, + context.getMainExecutor(), + callback); + } + + @VisibleForTesting + ShortcutLoader( + Context context, + @Nullable AppPredictorProxy appPredictor, + UserHandle userHandle, + boolean isPersonalProfile, + @Nullable IntentFilter targetIntentFilter, + Executor backgroundExecutor, + Executor callbackExecutor, + Consumer callback) { + mContext = context; + mAppPredictor = appPredictor; + mUserHandle = userHandle; + mTargetIntentFilter = targetIntentFilter; + mBackgroundExecutor = backgroundExecutor; + mCallbackExecutor = callbackExecutor; + mCallback.set(callback); + mIsPersonalProfile = isPersonalProfile; + mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE); + + if (mAppPredictor != null) { + mAppPredictorCallback = createAppPredictorCallback(); + mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback); + } else { + mAppPredictorCallback = null; + } + } + + /** + * Unsubscribe from app predictor if one was provided. + */ + @MainThread + public void destroy() { + if (mCallback.getAndSet(null) != null) { + if (mAppPredictor != null) { + mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback); + } + } + } + + private boolean isDestroyed() { + return mCallback.get() == null; + } + + /** + * Set new resolved targets. This will trigger shortcut loading. + * @param appTargets a collection of application targets a loaded set of shortcuts will be + * grouped against + */ + @MainThread + public void queryShortcuts(DisplayResolveInfo[] appTargets) { + if (isDestroyed()) { + return; + } + mActiveRequest.set(new Request(appTargets)); + mBackgroundExecutor.execute(this::loadShortcuts); + } + + @WorkerThread + private void loadShortcuts() { + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryDirectShareTargets()) { + return; + } + Log.d(TAG, "querying direct share targets"); + queryDirectShareTargets(false); + } + + @WorkerThread + private void queryDirectShareTargets(boolean skipAppPredictionService) { + if (isDestroyed()) { + return; + } + if (!skipAppPredictionService && mAppPredictor != null) { + mAppPredictor.requestPredictionUpdate(); + return; + } + // Default to just querying ShortcutManager if AppPredictor not present. + if (mTargetIntentFilter == null) { + return; + } + + Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); + ShortcutManager sm = (ShortcutManager) selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE); + List shortcuts = + sm.getShareTargets(mTargetIntentFilter); + sendShareShortcutInfoList(shortcuts, false, null); + } + + private AppPredictor.Callback createAppPredictorCallback() { + return appPredictorTargets -> { + if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(true); + return; + } + + final List shortcuts = new ArrayList<>(); + List shortcutResults = new ArrayList<>(); + for (AppTarget appTarget : appPredictorTargets) { + if (appTarget.getShortcutInfo() == null) { + continue; + } + shortcutResults.add(appTarget); + } + appPredictorTargets = shortcutResults; + for (AppTarget appTarget : appPredictorTargets) { + shortcuts.add(new ShortcutManager.ShareShortcutInfo( + appTarget.getShortcutInfo(), + new ComponentName(appTarget.getPackageName(), appTarget.getClassName()))); + } + sendShareShortcutInfoList(shortcuts, true, appPredictorTargets); + }; + } + + @WorkerThread + private void sendShareShortcutInfoList( + List shortcuts, + boolean isFromAppPredictor, + @Nullable List appPredictorTargets) { + if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) { + throw new RuntimeException("resultList and appTargets must have the same size." + + " resultList.size()=" + shortcuts.size() + + " appTargets.size()=" + appPredictorTargets.size()); + } + Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); + for (int i = shortcuts.size() - 1; i >= 0; i--) { + final String packageName = shortcuts.get(i).getTargetComponent().getPackageName(); + if (!isPackageEnabled(selectedProfileContext, packageName)) { + shortcuts.remove(i); + if (appPredictorTargets != null) { + appPredictorTargets.remove(i); + } + } + } + + HashMap directShareAppTargetCache = new HashMap<>(); + HashMap directShareShortcutInfoCache = new HashMap<>(); + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets; + List resultRecords = new ArrayList<>(); + for (DisplayResolveInfo displayResolveInfo : appTargets) { + List matchingShortcuts = + filterShortcutsByTargetComponentName( + shortcuts, displayResolveInfo.getResolvedComponentName()); + if (matchingShortcuts.isEmpty()) { + continue; + } + + List chooserTargets = mShortcutToChooserTargetConverter + .convertToChooserTarget( + matchingShortcuts, + shortcuts, + appPredictorTargets, + directShareAppTargetCache, + directShareShortcutInfoCache); + + ShortcutResultInfo resultRecord = + new ShortcutResultInfo(displayResolveInfo, chooserTargets); + resultRecords.add(resultRecord); + } + + postReport( + new Result( + isFromAppPredictor, + appTargets, + resultRecords.toArray(new ShortcutResultInfo[0]), + directShareAppTargetCache, + directShareShortcutInfoCache)); + } + + private void postReport(Result result) { + mCallbackExecutor.execute(() -> report(result)); + } + + @MainThread + private void report(Result result) { + Consumer callback = mCallback.get(); + if (callback != null) { + callback.accept(result); + } + } + + /** + * Returns {@code false} if {@code userHandle} is the work profile and it's either + * in quiet mode or not running. + */ + private boolean shouldQueryDirectShareTargets() { + return mIsPersonalProfile || isProfileActive(); + } + + @VisibleForTesting + protected boolean isProfileActive() { + return mUserManager.isUserRunning(mUserHandle) + && mUserManager.isUserUnlocked(mUserHandle) + && !mUserManager.isQuietModeEnabled(mUserHandle); + } + + private static boolean isPackageEnabled(Context context, String packageName) { + if (TextUtils.isEmpty(packageName)) { + return false; + } + ApplicationInfo appInfo; + try { + appInfo = context.getPackageManager().getApplicationInfo( + packageName, + ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (NameNotFoundException e) { + return false; + } + + return appInfo != null && appInfo.enabled + && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0; + } + + private static List filterShortcutsByTargetComponentName( + List allShortcuts, ComponentName requiredTarget) { + List matchingShortcuts = new ArrayList<>(); + for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { + if (requiredTarget.equals(shortcut.getTargetComponent())) { + matchingShortcuts.add(shortcut); + } + } + return matchingShortcuts; + } + + private static class Request { + public final DisplayResolveInfo[] appTargets; + + Request(DisplayResolveInfo[] targets) { + appTargets = targets; + } + } + + /** + * Resolved shortcuts with corresponding app targets. + */ + public static class Result { + public final boolean isFromAppPredictor; + /** + * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the + * shortcuts were process against. + */ + public final DisplayResolveInfo[] appTargets; + /** + * Shortcuts grouped by app target. + */ + public final ShortcutResultInfo[] shortcutsByApp; + public final Map directShareAppTargetCache; + public final Map directShareShortcutInfoCache; + + @VisibleForTesting + public Result( + boolean isFromAppPredictor, + DisplayResolveInfo[] appTargets, + ShortcutResultInfo[] shortcutsByApp, + Map directShareAppTargetCache, + Map directShareShortcutInfoCache) { + this.isFromAppPredictor = isFromAppPredictor; + this.appTargets = appTargets; + this.shortcutsByApp = shortcutsByApp; + this.directShareAppTargetCache = directShareAppTargetCache; + this.directShareShortcutInfoCache = directShareShortcutInfoCache; + } + } + + /** + * Shortcuts grouped by app. + */ + public static class ShortcutResultInfo { + public final DisplayResolveInfo appTarget; + public final List shortcuts; + + public ShortcutResultInfo(DisplayResolveInfo appTarget, List shortcuts) { + this.appTarget = appTarget; + this.shortcuts = shortcuts; + } + } + + /** + * A wrapper around AppPredictor to facilitate unit-testing. + */ + @VisibleForTesting + public static class AppPredictorProxy { + private final AppPredictor mAppPredictor; + + AppPredictorProxy(AppPredictor appPredictor) { + mAppPredictor = appPredictor; + } + + /** + * {@link AppPredictor#registerPredictionUpdates} + */ + public void registerPredictionUpdates( + Executor callbackExecutor, AppPredictor.Callback callback) { + mAppPredictor.registerPredictionUpdates(callbackExecutor, callback); + } + + /** + * {@link AppPredictor#unregisterPredictionUpdates} + */ + public void unregisterPredictionUpdates(AppPredictor.Callback callback) { + mAppPredictor.unregisterPredictionUpdates(callback); + } + + /** + * {@link AppPredictor#requestPredictionUpdate} + */ + public void requestPredictionUpdate() { + mAppPredictor.requestPredictionUpdate(); + } + } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java new file mode 100644 index 00000000..a37d6558 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.prediction.AppTarget; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class ShortcutToChooserTargetConverter { + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget + * instances back to original allAppTargets. + * @param directShareShortcutInfoCache An optional map to store mapping from the new + * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()} + * @return A list of ChooserTargets sorted by score in descending order. + */ + @NonNull + public List convertToChooserTarget( + @NonNull List matchingShortcuts, + @NonNull List allShortcuts, + @Nullable List allAppTargets, + @Nullable Map directShareAppTargetCache, + @Nullable Map directShareShortcutInfoCache) { + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final boolean isFromAppPredictor = allAppTargets != null; + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List scoreList = new ArrayList<>(); + if (!isFromAppPredictor) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (isFromAppPredictor) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (directShareAppTargetCache != null && allAppTargets != null) { + directShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (directShareShortcutInfoCache != null) { + directShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } +} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index b220d3ea..306eccb9 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -25,7 +25,7 @@ - + diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index e474938b..dd78b69e 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -24,15 +24,17 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; -import android.util.Pair; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import java.util.List; -import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Function; +import kotlin.jvm.functions.Function2; + /** * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. * We cannot directly mock the activity created since instrumentation creates it, so instead we use @@ -51,10 +53,8 @@ public class ChooserActivityOverrideData { @SuppressWarnings("Since15") public Function createPackageManager; public Function onSafelyStartCallback; - public Function onQueryDirectShareTargets; - public BiFunction< - IChooserWrapper, ChooserListAdapter, Pair> - directShareTargets; + public Function2, ShortcutLoader> + shortcutLoaderFactory = (userHandle, callback) -> null; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; @@ -69,15 +69,11 @@ public class ChooserActivityOverrideData { public UserHandle workProfileUserHandle; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public boolean isWorkProfileUserRunning; - public boolean isWorkProfileUserUnlocked; public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; public PackageManager packageManager; public void reset() { onSafelyStartCallback = null; - onQueryDirectShareTargets = null; - directShareTargets = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; @@ -93,8 +89,6 @@ public class ChooserActivityOverrideData { workProfileUserHandle = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; - isWorkProfileUserRunning = true; - isWorkProfileUserUnlocked = true; packageManager = null; multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { @Override @@ -114,6 +108,7 @@ public class ChooserActivityOverrideData { isQuietModeEnabled = enabled; } }; + shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8c7c28bb..6b74fcd4 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -19,11 +19,13 @@ package com.android.intentresolver; import static org.mockito.Mockito.when; import android.annotation.Nullable; +import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; @@ -31,7 +33,6 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.util.Pair; import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter; @@ -44,11 +45,12 @@ import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; /** * Simple wrapper around chooser activity to be able to initiate it under test. For more @@ -256,41 +258,18 @@ public class ChooserWrapperActivity } @Override - protected void queryDirectShareTargets( - ChooserListAdapter adapter, boolean skipAppPredictionService) { - if (sOverrides.directShareTargets != null) { - Pair result = - sOverrides.directShareTargets.apply(this, adapter); - // Imitate asynchronous shortcut loading - getMainExecutor().execute( - () -> onShortcutsLoaded( - adapter, result.first, Arrays.asList(result.second))); - return; - } - if (sOverrides.onQueryDirectShareTargets != null) { - sOverrides.onQueryDirectShareTargets.apply(adapter); - } - super.queryDirectShareTargets(adapter, skipAppPredictionService); - } - - @Override - protected boolean isQuietModeEnabled(UserHandle userHandle) { - return sOverrides.isQuietModeEnabled; - } - - @Override - protected boolean isUserRunning(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserRunning(userHandle); - } - return sOverrides.isWorkProfileUserRunning; - } - - @Override - protected boolean isUserUnlocked(UserHandle userHandle) { - if (userHandle.equals(UserHandle.SYSTEM)) { - return super.isUserUnlocked(userHandle); + protected ShortcutLoader createShortcutLoader( + Context context, + AppPredictor appPredictor, + UserHandle userHandle, + IntentFilter targetIntentFilter, + Consumer callback) { + ShortcutLoader shortcutLoader = + sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); + if (shortcutLoader != null) { + return shortcutLoader; } - return sOverrides.isWorkProfileUserUnlocked; + return super.createShortcutLoader( + context, appPredictor, userHandle, targetIntentFilter, callback); } } diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index f81cd023..0d44e147 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -25,6 +25,8 @@ import android.os.UserHandle; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; +import java.util.concurrent.Executor; + /** * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in * order to expose the internals for override/inspection. Implementations should apply the overrides @@ -41,4 +43,5 @@ public interface IChooserWrapper { @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); ChooserActivityLogger getChooserActivityLogger(); + Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt deleted file mode 100644 index 5529e714..00000000 --- a/java/tests/src/com/android/intentresolver/ShortcutToChooserTargetConverterTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2022 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.intentresolver - -import android.app.prediction.AppTarget -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.service.chooser.ChooserTarget -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test - -private const val PACKAGE = "org.package" - -class ShortcutToChooserTargetConverterTest { - private val testSubject = ShortcutToChooserTargetConverter() - private val ranks = arrayOf(3 ,7, 1 ,3) - private val shortcuts = ranks - .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> - val id = i + 1 - acc.add( - createShareShortcutInfo( - id = "id-$i", - componentName = ComponentName(PACKAGE, "Class$id"), - rank, - ) - ) - acc - } - - @Test - fun testConvertToChooserTarget_predictionService() { - val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } - val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) - val appTargetCache = HashMap() - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderAllShortcuts, - expectedScoreAllShortcuts, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset = shortcuts.subList(1, shortcuts.size) - val expectedOrderSubset = intArrayOf(1, 2, 3) - val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) - appTargetCache.clear() - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderSubset, - expectedScoreSubset, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - @Test - fun testConvertToChooserTarget_shortcutManager() { - val testSubject = ShortcutToChooserTargetConverter() - val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) - val shortcutInfoCache = HashMap() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset: MutableList = java.util.ArrayList() - subset.add(shortcuts[1]) - subset.add(shortcuts[2]) - subset.add(shortcuts[3]) - val expectedOrderSubset = intArrayOf(2, 3, 1) - val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - private fun assertCorrectShortcutToChooserTargetConversion( - shortcuts: List, - chooserTargets: List, - expectedOrder: IntArray, - expectedScores: FloatArray, - ) { - assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) - for (i in chooserTargets.indices) { - val ct = chooserTargets[i] - val si = shortcuts[expectedOrder[i]].shortcutInfo - val cn = shortcuts[expectedOrder[i]].targetComponent - assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) - assertEquals(si.label, ct.title) - assertEquals(expectedScores[i], ct.score) - assertEquals(cn, ct.componentName) - } - } - - private fun assertAppTargetCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val target = cache[ct] - assertNotNull("AppTarget is missing", target) - } - } - - private fun assertShortcutInfoCache( - chooserTargets: List, cache: Map - ) { - for (ct in chooserTargets) { - val si = cache[ct] - assertNotNull("AppTarget is missing", si) - } - } -} diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt new file mode 100644 index 00000000..849cfbab --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestApplication.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.Application +import android.content.Context +import android.os.UserHandle + +class TestApplication : Application() { + + // return the current context as a work profile doesn't really exist in these tests + override fun createContextAsUser(user: UserHandle, flags: Int): Context = this +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 7c304284..da72a749 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -38,7 +38,6 @@ import static com.android.intentresolver.MatcherUtils.first; import static com.google.common.truth.Truth.assertThat; -import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; import static org.hamcrest.CoreMatchers.allOf; @@ -83,6 +82,7 @@ import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.util.Pair; +import android.util.SparseArray; import android.view.View; import androidx.annotation.CallSuper; @@ -93,9 +93,9 @@ import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ChooserActivity.ServiceResultInfo; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -118,6 +118,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -1279,7 +1280,7 @@ public class UnbundledChooserActivityTest { } // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore + @Test public void testDirectTargetSelectionLogging() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1298,37 +1299,55 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, ""); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, ""); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1336,23 +1355,29 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + LogMaker selectionLog = logMakerCaptor.getAllValues().get(3); + assertThat( + selectionLog.getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - String hashedName = (String) logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); - assertThat("Hash is not predictable but must be obfuscated", + String hashedName = (String) selectionLog.getTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat( + "Hash is not predictable but must be obfuscated", hashedName, is(not(name))); - assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + assertThat( + "The packages shouldn't match for app target and direct target", + selectionLog.getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), + is(-1)); } // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore + @Test public void testDirectTargetLoggingWithRankedAppTarget() { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1371,38 +1396,57 @@ public class UnbundledChooserActivityTest { // Set up resources MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> activity.getAdapter().addServiceResults( - activity.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -1410,18 +1454,19 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + // Currently we're seeing 4 invocations + // 1. ChooserActivity.logActionShareWithPreview() + // 2. ChooserActivity.onCreate() + // 3. ChooserActivity.logDirectShareTargetReceived() + // 4. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(3).getCategory(), is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); assertThat("The packages should match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + .getAllValues().get(3).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); } - @Test @Ignore + @Test public void testShortcutTargetWithApplyAppLimits() { // Set up resources ChooserActivityOverrideData.getInstance().resources = Mockito.spy( @@ -1445,48 +1490,64 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - List shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - wrapper.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly one selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); } - @Test @Ignore + @Test public void testShortcutTargetWithoutApplyAppLimits() { setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, @@ -1513,47 +1574,65 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); // Start activity - final ChooserActivity activity = + final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); - // Insert the direct share target - Map directShareToShortcutInfos = new HashMap<>(); - List shortcutInfos = createShortcuts(activity); - directShareToShortcutInfos.put(serviceTargets.get(0), - shortcutInfos.get(0).getShortcutInfo()); - directShareToShortcutInfos.put(serviceTargets.get(1), - shortcutInfos.get(1).getShortcutInfo()); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets( + 2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); - assertThat("Chooser should have 4 targets (2 apps, 2 direct)", - wrapper.getAdapter().getCount(), is(4)); - assertThat("Chooser should have exactly two selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(2)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - assertThat("The display label must match", - wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); - assertThat("The display label must match", - wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1")); + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 4 targets (2 apps, 2 direct)", + activeAdapter.getCount(), + is(4)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(2)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activeAdapter.getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is("testTitle0")); + assertThat( + "The display label must match", + activeAdapter.getItem(1).getDisplayLabel(), + is("testTitle1")); } @Test @@ -1948,43 +2027,59 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Create direct share target - List serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); - - ChooserActivityOverrideData - .getInstance() - .directShareTargets = (activity, adapter) -> { - DisplayResolveInfo displayInfo = activity.createTestDisplayResolveInfo( - sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null); - ServiceResultInfo[] results = { - new ServiceResultInfo(displayInfo, serviceTargets) }; - // TODO: consider covering the other type. - // Only 2 types are expected out of the shortcut loading logic: - // - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, if shortcuts were loaded from - // the ShortcutManager, and; - // - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, if shortcuts were loaded - // from AppPredictor. - // Ideally, our tests should cover all of them. - return new Pair<>(TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, results); + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; }; // Start activity final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + List serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); assertThat("Chooser should have 3 targets (2 apps, 1 direct)", activity.getAdapter().getCount(), is(3)); assertThat("Chooser should have exactly one selectable direct target", activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat( + "The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), + is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); // Click on the direct target String name = serviceTargets.get(0).getTitle().toString(); @@ -2098,7 +2193,7 @@ public class UnbundledChooserActivityTest { return true; }; - mActivityRule.launchActivity(sendIntent); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); waitForIdle(); assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); @@ -2273,21 +2368,20 @@ public class UnbundledChooserActivityTest { } @Test - public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { + public void test_query_shortcut_loader_for_the_selected_tab() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List workResolvedComponentInfos = createResolvedComponentsForTest(3); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; + ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); + ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); + final SparseArray shortcutLoaders = new SparseArray<>(); + shortcutLoaders.put(0, personalProfileShortcutLoader); + shortcutLoaders.put(10, workProfileShortcutLoader); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2295,118 +2389,14 @@ public class UnbundledChooserActivityTest { waitForIdle(); onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - assertFalse("Direct share targets were queried on a paused work profile", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); + verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any()); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_workUserNotRunning_workTargetsShown() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertEquals(3, wrapper.getWorkListAdapter().getCount()); - } - - @Test - public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; - ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = - chooserListAdapter -> { - isQueryDirectShareCalledOnWorkProfile[0] = - (chooserListAdapter.getUserHandle().getIdentifier() == 10); - return null; - }; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertFalse("Direct share targets were queried on a locked work profile user", - isQueryDirectShareCalledOnWorkProfile[0]); - } - - @Test - public void testWorkTab_workUserLocked_workTargetsShown() { - markWorkProfileUserAvailable(); - List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertEquals(3, wrapper.getWorkListAdapter().getCount()); + verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); } private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { @@ -2713,4 +2703,18 @@ public class UnbundledChooserActivityTest { .getInteger(R.integer.config_chooser_max_targets_per_row)) .thenReturn(targetsPerRow); } + + private SparseArray>> + createShortcutLoaderFactory() { + SparseArray>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + return shortcutLoaders; + } } diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt new file mode 100644 index 00000000..5756a0cd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts + +import android.app.prediction.AppPredictor +import android.content.ComponentName +import android.content.Context +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ApplicationInfoFlags +import android.content.pm.ShortcutManager +import android.os.UserHandle +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.intentresolver.any +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.concurrent.Executor +import java.util.function.Consumer + +@SmallTest +class ShortcutLoaderTest { + private val appInfo = ApplicationInfo().apply { + enabled = true + flags = 0 + } + private val pm = mock { + whenever(getApplicationInfo(any(), any())).thenReturn(appInfo) + } + private val context = mock { + whenever(packageManager).thenReturn(pm) + whenever(createContextAsUser(any(), anyInt())).thenReturn(this) + } + private val executor = ImmediateExecutor() + private val intentFilter = mock() + private val appPredictor = mock() + private val callback = mock>() + + @Test + fun test_app_predictor_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val matchingAppTarget = createAppTarget(matchingShortcutInfo) + val shortcuts = listOf( + matchingAppTarget, + // mismatching shortcut + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + ) + appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertTrue("An app predictor result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertEquals( + "Wrong AppTarget in the cache", + matchingAppTarget, + result.directShareAppTargetCache[shortcut] + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_shortcut_manager_result() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + null, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_fallback_to_shortcut_manager() { + val componentName = ComponentName("pkg", "Class") + val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + val appTargets = arrayOf(appTarget) + val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) + verify(callback, times(1)).accept(resultCaptor.capture()) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + + @Test + fun test_do_not_call_services_for_not_running_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) + } + + @Test + fun test_do_not_call_services_for_locked_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) + } + + @Test + fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) + } + + @Test + fun test_call_services_for_not_running_main_profile() { + testAlwaysCallSystemForMainProfile(isUserRunning = false) + } + + @Test + fun test_call_services_for_locked_main_profile() { + testAlwaysCallSystemForMainProfile(isUserUnlocked = false) + } + + @Test + fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { + testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) + } + + private fun testDisabledWorkProfileDoNotCallSystem( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock() + val callback = mock>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + false, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf(mock())) + + verify(appPredictor, never()).requestPredictionUpdate() + } + + private fun testAlwaysCallSystemForMainProfile( + isUserRunning: Boolean = true, + isUserUnlocked: Boolean = true, + isQuietModeEnabled: Boolean = false + ) { + val userHandle = UserHandle.of(10) + val userManager = mock { + whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) + whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) + whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) + } + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + val appPredictor = mock() + val callback = mock>() + val testSubject = ShortcutLoader( + context, + appPredictor, + userHandle, + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(arrayOf(mock())) + + verify(appPredictor, times(1)).requestPredictionUpdate() + } +} + +private class ImmediateExecutor : Executor { + override fun execute(r: Runnable) { + r.run() + } +} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt new file mode 100644 index 00000000..e0de005d --- /dev/null +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2022 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.intentresolver.shortcuts + +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.service.chooser.ChooserTarget +import com.android.intentresolver.createAppTarget +import com.android.intentresolver.createShareShortcutInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +private const val PACKAGE = "org.package" + +class ShortcutToChooserTargetConverterTest { + private val testSubject = ShortcutToChooserTargetConverter() + private val ranks = arrayOf(3 ,7, 1 ,3) + private val shortcuts = ranks + .foldIndexed(ArrayList(ranks.size)) { i, acc, rank -> + val id = i + 1 + acc.add( + createShareShortcutInfo( + id = "id-$i", + componentName = ComponentName(PACKAGE, "Class$id"), + rank, + ) + ) + acc + } + + @Test + fun testConvertToChooserTarget_predictionService() { + val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } + val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) + val appTargetCache = HashMap() + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset = shortcuts.subList(1, shortcuts.size) + val expectedOrderSubset = intArrayOf(1, 2, 3) + val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) + appTargetCache.clear() + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset, + ) + assertAppTargetCache(chooserTargets, appTargetCache) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + @Test + fun testConvertToChooserTarget_shortcutManager() { + val testSubject = ShortcutToChooserTargetConverter() + val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) + val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) + val shortcutInfoCache = HashMap() + + var chooserTargets = testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + + val subset: MutableList = java.util.ArrayList() + subset.add(shortcuts[1]) + subset.add(shortcuts[2]) + subset.add(shortcuts[3]) + val expectedOrderSubset = intArrayOf(2, 3, 1) + val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) + shortcutInfoCache.clear() + + chooserTargets = testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) + + assertCorrectShortcutToChooserTargetConversion( + shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset + ) + assertShortcutInfoCache(chooserTargets, shortcutInfoCache) + } + + private fun assertCorrectShortcutToChooserTargetConversion( + shortcuts: List, + chooserTargets: List, + expectedOrder: IntArray, + expectedScores: FloatArray, + ) { + assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) + for (i in chooserTargets.indices) { + val ct = chooserTargets[i] + val si = shortcuts[expectedOrder[i]].shortcutInfo + val cn = shortcuts[expectedOrder[i]].targetComponent + assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) + assertEquals(si.label, ct.title) + assertEquals(expectedScores[i], ct.score) + assertEquals(cn, ct.componentName) + } + } + + private fun assertAppTargetCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val target = cache[ct] + assertNotNull("AppTarget is missing", target) + } + } + + private fun assertShortcutInfoCache( + chooserTargets: List, cache: Map + ) { + for (ct in chooserTargets) { + val si = cache[ct] + assertNotNull("AppTarget is missing", si) + } + } +} -- cgit v1.2.3-59-g8ed1b From 0919a83ee94b469eba0a72ee6e79fddb1e7dc7f7 Mon Sep 17 00:00:00 2001 From: Nick Chameyev Date: Mon, 10 Oct 2022 12:39:19 +0000 Subject: [Partial Screensharing] Add abstraction to show custom device policy blockers in ChooserActivity Adds an interface that controls the behavior of the blocked empty state of personal/work profile tabs in the ChooserActivity/ResolverActivity. This state is displayed when the device policy doesn't allow sharing between apps. The interface allows to customize in which cases we block the sharing, what text we display there and send custom analytics events. This CL should not change any behaviour. Default behaviour is to not allow cross profile sharing which could be overriden by using different implementation of the ProfileBlockerEmptyStateProvider. E.g. in partial screensharing app selector we could decide whether we want to block the tab based on the screen capturing device policies. Bug: 233348916 Test: atest com.android.internal.app.ChooserActivityTest Test: atest com.android.internal.app.ResolverActivityTest Test: atest com.android.internal.app.ChooserActivityWorkProfileTest Test: atest com.android.internal.app.ResolverActivityWorkProfileTest Change-Id: I2b74b007b80d81ef7cd06456c2dfe42ccfc3f1d1 --- .../AbstractMultiProfilePagerAdapter.java | 394 ++++++++---------- .../android/intentresolver/ChooserActivity.java | 56 ++- .../ChooserMultiProfilePagerAdapter.java | 136 +----- .../NoAppsAvailableEmptyStateProvider.java | 154 +++++++ .../NoCrossProfileEmptyStateProvider.java | 137 +++++++ .../android/intentresolver/ResolverActivity.java | 171 +++++++- .../intentresolver/ResolverListAdapter.java | 1 - .../ResolverMultiProfilePagerAdapter.java | 112 +---- .../WorkProfilePausedEmptyStateProvider.java | 114 ++++++ .../ChooserActivityOverrideData.java | 42 +- .../intentresolver/ChooserWrapperActivity.java | 42 +- .../intentresolver/ResolverWrapperActivity.java | 65 ++- .../UnbundledChooserActivityWorkProfileTest.java | 455 +++++++++++++++++++++ 13 files changed, 1371 insertions(+), 508 deletions(-) create mode 100644 java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java create mode 100644 java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java create mode 100644 java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 0f0d1797..3e1084f4 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -17,18 +17,15 @@ package com.android.intentresolver; import android.annotation.IntDef; import android.annotation.Nullable; +import android.annotation.NonNull; +import android.annotation.UserIdInt; import android.app.AppGlobals; -import android.app.admin.DevicePolicyEventLogger; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.pm.IPackageManager; -import android.content.pm.ResolveInfo; -import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; -import android.os.UserManager; -import android.stats.devicepolicy.DevicePolicyEnums; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -60,73 +57,32 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { private final Context mContext; private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private Set mLoadedPages; - private final UserHandle mPersonalProfileUserHandle; + private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; - private Injector mInjector; - private boolean mIsWaitingToEnableWorkProfile; + private final QuietModeManager mQuietModeManager; AbstractMultiProfilePagerAdapter(Context context, int currentPage, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); - mPersonalProfileUserHandle = personalProfileUserHandle; mWorkProfileUserHandle = workProfileUserHandle; - UserManager userManager = context.getSystemService(UserManager.class); - mInjector = new Injector() { - @Override - public boolean hasCrossProfileIntents(List intents, int sourceUserId, - int targetUserId) { - return AbstractMultiProfilePagerAdapter.this - .hasCrossProfileIntents(intents, sourceUserId, targetUserId); - } - - @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return userManager.isQuietModeEnabled(workProfileUserHandle); - } - - @Override - public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); - }); - mIsWaitingToEnableWorkProfile = true; - } - }; - } - - protected void markWorkProfileEnabledBroadcastReceived() { - mIsWaitingToEnableWorkProfile = false; - } - - protected boolean isWaitingToEnableWorkProfile() { - return mIsWaitingToEnableWorkProfile; - } - - /** - * Overrides the default {@link Injector} for testing purposes. - */ - @VisibleForTesting - public void setInjector(Injector injector) { - mInjector = injector; + mEmptyStateProvider = emptyStateProvider; + mQuietModeManager = quietModeManager; } - protected boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return mInjector.isQuietModeEnabled(workProfileUserHandle); + private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle); } void setOnProfileSelectedListener(OnProfileSelectedListener listener) { mOnProfileSelectedListener = listener; } - void setOnSwitchOnWorkSelectedListener(OnSwitchOnWorkSelectedListener listener) { - mOnSwitchOnWorkSelectedListener = listener; - } - Context getContext() { return mContext; } @@ -280,8 +236,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { abstract @Nullable ViewGroup getInactiveAdapterView(); - abstract String getMetricsCategory(); - /** * Rebuilds the tab that is currently visible to the user. *

    Returns {@code true} if rebuild has completed. @@ -317,41 +271,18 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { - if (shouldShowNoCrossProfileIntentsEmptyState(activeListAdapter)) { + if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); return false; } return activeListAdapter.rebuildList(doPostProcessing); } - private boolean shouldShowNoCrossProfileIntentsEmptyState( - ResolverListAdapter activeListAdapter) { - UserHandle listUserHandle = activeListAdapter.getUserHandle(); - return UserHandle.myUserId() != listUserHandle.getIdentifier() - && allowShowNoCrossProfileIntentsEmptyState() - && !mInjector.hasCrossProfileIntents(activeListAdapter.getIntents(), - UserHandle.myUserId(), listUserHandle.getIdentifier()); + private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); } - boolean allowShowNoCrossProfileIntentsEmptyState() { - return true; - } - - protected abstract void showWorkProfileOffEmptyState( - ResolverListAdapter activeListAdapter, View.OnClickListener listener); - - protected abstract void showNoPersonalToWorkIntentsEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoPersonalAppsAvailableEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoWorkAppsAvailableEmptyState( - ResolverListAdapter activeListAdapter); - - protected abstract void showNoWorkToPersonalIntentsEmptyState( - ResolverListAdapter activeListAdapter); - /** * The empty state screens are shown according to their priority: *

      @@ -366,103 +297,88 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * anyway. */ void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { - if (maybeShowNoCrossProfileIntentsEmptyState(listAdapter)) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { return; } - if (maybeShowWorkProfileOffEmptyState(listAdapter)) { - 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()); + }); } - maybeShowNoAppsAvailableEmptyState(listAdapter); + + showEmptyState(listAdapter, emptyState, clickListener); } - private boolean maybeShowNoCrossProfileIntentsEmptyState(ResolverListAdapter listAdapter) { - if (!shouldShowNoCrossProfileIntentsEmptyState(listAdapter)) { - return false; - } - if (listAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL) - .setStrings(getMetricsCategory()) - .write(); - showNoWorkToPersonalIntentsEmptyState(listAdapter); - } else { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK) - .setStrings(getMetricsCategory()) - .write(); - showNoPersonalToWorkIntentsEmptyState(listAdapter); + /** + * 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(); } - return true; } /** - * Returns {@code true} if the work profile off empty state screen is shown. + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests */ - private boolean maybeShowWorkProfileOffEmptyState(ResolverListAdapter listAdapter) { - UserHandle listUserHandle = listAdapter.getUserHandle(); - if (!listUserHandle.equals(mWorkProfileUserHandle) - || !mInjector.isQuietModeEnabled(mWorkProfileUserHandle) - || listAdapter.getCount() == 0) { - return false; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(getMetricsCategory()) - .write(); - showWorkProfileOffEmptyState(listAdapter, - v -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - showSpinner(descriptor.getEmptyStateView()); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mInjector.requestQuietModeEnabled(false, mWorkProfileUserHandle); - }); - return true; - } - - private void maybeShowNoAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - UserHandle listUserHandle = listAdapter.getUserHandle(); - if (mWorkProfileUserHandle != null - && (UserHandle.myUserId() == listUserHandle.getIdentifier() - || !hasAppsInOtherProfile(listAdapter))) { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(getMetricsCategory()) - .setBoolean(/*isPersonalProfile*/ listUserHandle == mPersonalProfileUserHandle) - .write(); - if (listUserHandle == mPersonalProfileUserHandle) { - showNoPersonalAppsAvailableEmptyState(listAdapter); - } else { - showNoWorkAppsAvailableEmptyState(listAdapter); - } - } else if (mWorkProfileUserHandle == null) { - showConsumerUserNoAppsAvailableEmptyState(listAdapter); + public static class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + mContentResolver = contentResolver; } - } - protected void showEmptyState(ResolverListAdapter activeListAdapter, String title, - String subtitle) { - showEmptyState(activeListAdapter, title, subtitle, /* buttonOnClick */ null); + /** + * 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, - String title, String subtitle, View.OnClickListener buttonOnClick) { + protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, + View.OnClickListener buttonOnClick) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForWorkProfileEmptyState(emptyStateView); + resetViewVisibilitiesForEmptyState(emptyStateView); emptyStateView.setVisibility(View.VISIBLE); View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); setupContainerPadding(container); TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); - titleView.setText(title); + String title = emptyState.getTitle(); + if (title != null) { + titleView.setVisibility(View.VISIBLE); + titleView.setText(title); + } else { + titleView.setVisibility(View.GONE); + } TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); + String subtitle = emptyState.getSubtitle(); if (subtitle != null) { subtitleView.setVisibility(View.VISIBLE); subtitleView.setText(subtitle); @@ -470,6 +386,9 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { subtitleView.setVisibility(View.GONE); } + View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); + defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); button.setOnClickListener(buttonOnClick); @@ -483,22 +402,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ protected void setupContainerPadding(View container) {} - private void showConsumerUserNoAppsAvailableEmptyState(ResolverListAdapter activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - View emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForConsumerUserEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); - - activeListAdapter.markTabLoaded(); - } - - private boolean isSpinnerShowing(View emptyStateView) { - return emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).getVisibility() - == View.VISIBLE; - } - private void showSpinner(View emptyStateView) { emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); @@ -506,7 +409,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); } - private void resetViewVisibilitiesForWorkProfileEmptyState(View emptyStateView) { + private void resetViewVisibilitiesForEmptyState(View emptyStateView) { emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); @@ -514,14 +417,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); } - private void resetViewVisibilitiesForConsumerUserEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.VISIBLE); - } - protected void showListView(ResolverListAdapter activeListAdapter) { ProfileDescriptor descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); @@ -530,33 +425,6 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { emptyStateView.setVisibility(View.GONE); } - private boolean hasCrossProfileIntents(List intents, int source, int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - ContentResolver contentResolver = mContext.getContentResolver(); - for (Intent intent : intents) { - if (IntentForwarderActivity.canForward(intent, source, target, packageManager, - contentResolver) != null) { - return true; - } - } - return false; - } - - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List resolversForIntent = - adapter.getResolversForUser(UserHandle.of(UserHandle.myUserId())); - for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } - boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) @@ -599,6 +467,99 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { 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. */ @@ -612,14 +573,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { /** * Describes an injector to be used for cross profile functionality. Overridable for testing. */ - @VisibleForTesting - public interface Injector { - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code sourceUserId} to {@code targetUserId}. - */ - boolean hasCrossProfileIntents(List intents, int sourceUserId, int targetUserId); - + public interface QuietModeManager { /** * Returns whether the given profile is in quiet mode or not. */ @@ -629,5 +583,15 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * 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(); } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index d5a0c32c..776d34a9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -16,6 +16,14 @@ package com.android.intentresolver; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; + import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.animation.Animator; @@ -99,6 +107,9 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -594,6 +605,41 @@ public class ChooserActivity extends ResolverActivity implements return mChooserMultiProfilePagerAdapter; } + @Override + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean isSendAction = isSendAction(getTargetIntent()); + + final EmptyState noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation + : R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + final EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState( + /* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ + isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation + : R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); + + return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), + noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), createMyUserIdProvider()); + } + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List rList, @@ -608,9 +654,10 @@ public class ChooserActivity extends ResolverActivity implements return new ChooserMultiProfilePagerAdapter( /* context */ this, adapter, - getPersonalProfileUserHandle(), + createEmptyStateProvider(/* workProfileUserHandle= */ null), + mQuietModeManager, /* workProfileUserHandle= */ null, - isSendAction(getTargetIntent()), mMaxTargetsPerRow); + mMaxTargetsPerRow); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( @@ -636,10 +683,11 @@ public class ChooserActivity extends ResolverActivity implements /* context */ this, personalAdapter, workAdapter, + createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), + mQuietModeManager, selectedProfile, - getPersonalProfileUserHandle(), getWorkProfileUserHandle(), - isSendAction(getTargetIntent()), mMaxTargetsPerRow); + mMaxTargetsPerRow); } private int findSelectedProfile() { diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 62c14866..d0463fff 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -16,17 +16,7 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - import android.annotation.Nullable; -import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; @@ -47,37 +37,37 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd private static final int SINGLE_CELL_SPAN_SIZE = 1; private final ChooserProfileDescriptor[] mItems; - private final boolean mIsSendAction; private int mBottomOffset; private int mMaxTargetsPerRow; ChooserMultiProfilePagerAdapter(Context context, ChooserActivity.ChooserGridAdapter adapter, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + int maxTargetsPerRow) { + super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, + workProfileUserHandle); mItems = new ChooserProfileDescriptor[] { createProfileDescriptor(adapter) }; - mIsSendAction = isSendAction; mMaxTargetsPerRow = maxTargetsPerRow; } ChooserMultiProfilePagerAdapter(Context context, ChooserActivity.ChooserGridAdapter personalAdapter, ChooserActivity.ChooserGridAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, UserHandle workProfileUserHandle, - boolean isSendAction, int maxTargetsPerRow) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, - workProfileUserHandle); + int maxTargetsPerRow) { + super(context, /* currentPage */ defaultProfile, emptyStateProvider, + quietModeManager, workProfileUserHandle); mItems = new ChooserProfileDescriptor[] { createProfileDescriptor(personalAdapter), createProfileDescriptor(workAdapter) }; - mIsSendAction = isSendAction; mMaxTargetsPerRow = maxTargetsPerRow; } @@ -192,112 +182,6 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd return getListViewForIndex(1 - getCurrentPage()); } - @Override - String getMetricsCategory() { - return ResolverActivity.METRICS_CATEGORY_CHOOSER; - } - - @Override - protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, - View.OnClickListener listener) { - showEmptyState(activeListAdapter, - getWorkAppPausedTitle(), - /* subtitle = */ null, - listener); - } - - @Override - protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithWorkMessage()); - } else { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessWorkMessage()); - } - } - - @Override - protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { - if (mIsSendAction) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantShareWithPersonalMessage()); - } else { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessPersonalMessage()); - } - } - - @Override - protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, getNoPersonalAppsAvailableMessage(), /* subtitle= */ null); - - } - - @Override - protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, getNoWorkAppsAvailableMessage(), /* subtitle = */ null); - } - - private String getWorkAppPausedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PAUSED_TITLE, - () -> getContext().getString(R.string.resolver_turn_on_work_apps)); - } - - private String getCrossProfileBlockedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - () -> getContext().getString(R.string.resolver_cross_profile_blocked)); - } - - private String getCantShareWithWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_SHARE_WITH_WORK, - () -> getContext().getString( - R.string.resolver_cant_share_with_work_apps_explanation)); - } - - private String getCantShareWithPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_SHARE_WITH_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_share_with_personal_apps_explanation)); - } - - private String getCantAccessWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_WORK, - () -> getContext().getString( - R.string.resolver_cant_access_work_apps_explanation)); - } - - private String getCantAccessPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_access_personal_apps_explanation)); - } - - private String getNoWorkAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> getContext().getString( - R.string.resolver_no_work_apps_available)); - } - - private String getNoPersonalAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> getContext().getString( - R.string.resolver_no_personal_apps_available)); - } - - void setEmptyStateBottomOffset(int bottomOffset) { mBottomOffset = bottomOffset; } diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..5bf994d6 --- /dev/null +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.internal.R; + +import java.util.List; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull + private final Context mContext; + @Nullable + private final UserHandle mWorkProfileUserHandle; + @Nullable + private final UserHandle mPersonalProfileUserHandle; + @NonNull + private final String mMetricsCategory; + @NonNull + private final MyUserIdProvider mMyUserIdProvider; + + public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, + UserHandle personalProfileUserHandle, String metricsCategory, + MyUserIdProvider myUserIdProvider) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mPersonalProfileUserHandle = personalProfileUserHandle; + mMetricsCategory = metricsCategory; + mMyUserIdProvider = myUserIdProvider; + } + + @Nullable + @Override + @SuppressWarnings("ReferenceEquality") + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + + if (mWorkProfileUserHandle != null + && (mMyUserIdProvider.getMyUserId() == listUserHandle.getIdentifier() + || !hasAppsInOtherProfile(resolverListAdapter))) { + + String title; + if (listUserHandle == mPersonalProfileUserHandle) { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + } else { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> mContext.getString(R.string.resolver_no_work_apps_available)); + } + + return new NoAppsAvailableEmptyState( + title, mMetricsCategory, + /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + ); + } else if (mWorkProfileUserHandle == null) { + // Return default empty state without tracking + return new DefaultEmptyState(); + } + + return null; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List resolversForIntent = + adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId())); + for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + public static class DefaultEmptyState implements EmptyState { + @Override + public boolean useDefaultEmptyView() { + return true; + } + } + + public static class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private String mTitle; + + @NonNull + private String mMetricsCategory; + + private boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(String title, String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } + } +} \ No newline at end of file diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..420d26c5 --- /dev/null +++ b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; + +/** + * Empty state provider that does not allow cross profile sharing, it will return a blocker + * in case if the profile of the current tab is not the same as the profile of the calling app. + */ +public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final MyUserIdProvider mUserIdProvider; + + public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + MyUserIdProvider myUserIdProvider) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mUserIdProvider = myUserIdProvider; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + mUserIdProvider.getMyUserId() != resolverListAdapter.getUserHandle().getIdentifier() + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mUserIdProvider.getMyUserId(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + @StringRes int defaultSubtitleResource, + int devicePolicyEventId, String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 46a41b50..fece8d3d 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -19,6 +19,9 @@ package com.android.intentresolver; import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; @@ -26,6 +29,8 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.PermissionChecker.PID_UNKNOWN; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import android.annotation.Nullable; @@ -56,6 +61,7 @@ import android.content.res.TypedArray; import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.PatternMatcher; @@ -93,7 +99,15 @@ import android.widget.Toast; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -178,6 +192,8 @@ public class ResolverActivity extends FragmentActivity implements @VisibleForTesting protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; + protected QuietModeManager mQuietModeManager; + // Intent extra for connected audio devices public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; @@ -209,6 +225,9 @@ public class ResolverActivity extends FragmentActivity implements private UserHandle mWorkProfileUserHandle; + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); private LatencyTracker getLatencyTracker() { @@ -366,6 +385,8 @@ public class ResolverActivity extends FragmentActivity implements setTheme(appliedThemeResId()); super.onCreate(savedInstanceState); + mQuietModeManager = createQuietModeManager(); + // Determine whether we should show that intent is forwarded // from managed profile to owner or other way around. setProfileSwitchMessage(intent.getContentUserHint()); @@ -466,6 +487,111 @@ public class ResolverActivity extends FragmentActivity implements return resolverMultiProfilePagerAdapter; } + @VisibleForTesting + protected MyUserIdProvider createMyUserIdProvider() { + return new MyUserIdProvider(); + } + + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + @VisibleForTesting + protected QuietModeManager createQuietModeManager() { + UserManager userManager = getSystemService(UserManager.class); + return new QuietModeManager() { + + private boolean mIsWaitingToEnableWorkProfile = false; + + @Override + public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return userManager.isQuietModeEnabled(workProfileUserHandle); + } + + @Override + public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); + }); + mIsWaitingToEnableWorkProfile = true; + } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + mIsWaitingToEnableWorkProfile = false; + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return mIsWaitingToEnableWorkProfile; + } + }; + } + + protected EmptyStateProvider createBlockerEmptyStateProvider() { + final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + + if (!shouldShowNoCrossProfileIntentsEmptyState) { + // Implementation that doesn't show any blockers + return new EmptyStateProvider() {}; + } + + final AbstractMultiProfilePagerAdapter.EmptyState + noWorkToPersonalEmptyState = + new DevicePolicyBlockerEmptyState(/* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_personal_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + + final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = + new DevicePolicyBlockerEmptyState(/* context= */ this, + /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, + /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, + /* defaultSubtitleResource= */ + R.string.resolver_cant_access_work_apps_explanation, + /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + + return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), + noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), createMyUserIdProvider()); + } + + protected EmptyStateProvider createEmptyStateProvider( + @Nullable UserHandle workProfileUserHandle) { + final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + final EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + mQuietModeManager, + /* onSwitchOnWorkSelectedListener= */ + () -> { if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + }}, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + getPersonalProfileUserHandle(), + getMetricsCategory(), + createMyUserIdProvider() + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List rList, boolean filterLastUsed) { @@ -476,13 +602,21 @@ public class ResolverActivity extends FragmentActivity implements rList, filterLastUsed, /* userHandle */ UserHandle.of(UserHandle.myUserId())); + QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, - getPersonalProfileUserHandle(), + createEmptyStateProvider(/* workProfileUserHandle= */ null), + quietModeManager, /* workProfileUserHandle= */ null); } + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getUser(); + } + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List rList, @@ -491,9 +625,7 @@ public class ResolverActivity extends FragmentActivity implements // the intent resolver is started in the other profile. Since this is the only case when // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); - UserHandle intentUser = getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getUser(); + UserHandle intentUser = getIntentUser(); if (!getUser().equals(intentUser)) { if (getPersonalProfileUserHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; @@ -526,14 +658,15 @@ public class ResolverActivity extends FragmentActivity implements (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), /* userHandle */ workProfileUserHandle); + QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, + createEmptyStateProvider(getWorkProfileUserHandle()), + quietModeManager, selectedProfile, - getPersonalProfileUserHandle(), - getWorkProfileUserHandle(), - /* shouldShowNoCrossProfileIntentsEmptyState= */ getUser().equals(intentUser)); + getWorkProfileUserHandle()); } protected int appliedThemeResId() { @@ -840,9 +973,9 @@ public class ResolverActivity extends FragmentActivity implements } mRegistered = true; } - if (shouldShowTabs() && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) { - if (mMultiProfilePagerAdapter.isQuietModeEnabled(getWorkProfileUserHandle())) { - mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { + if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { + mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); @@ -1799,13 +1932,12 @@ public class ResolverActivity extends FragmentActivity implements onHorizontalSwipeStateChanged(state); } }); - mMultiProfilePagerAdapter.setOnSwitchOnWorkSelectedListener( - () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } private String getPersonalTabLabel() { @@ -2066,7 +2198,7 @@ public class ResolverActivity extends FragmentActivity implements public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) { + && mQuietModeManager.isWaitingToEnableWorkProfile()) { // We have just turned on the work profile and entered the pass code to start it, // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no // point in reloading the list now, since the work profile user is still @@ -2118,7 +2250,7 @@ public class ResolverActivity extends FragmentActivity implements } mWorkProfileHasBeenEnabled = true; - mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); } else { // Must be an UNAVAILABLE broadcast, so we watch for the next availability mWorkProfileHasBeenEnabled = false; @@ -2134,7 +2266,6 @@ public class ResolverActivity extends FragmentActivity implements }; } - @VisibleForTesting public static final class ResolvedComponentInfo { public final ComponentName name; private final List mIntents = new ArrayList<>(); diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index f74c33c0..d97191c6 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -764,7 +764,6 @@ public class ResolverListAdapter extends BaseAdapter { } } - @VisibleForTesting public UserHandle getUserHandle() { return mResolverListController.getUserHandle(); } diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 7cd38a7e..8cf65529 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -16,15 +16,7 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - import android.annotation.Nullable; -import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; @@ -43,34 +35,33 @@ import com.android.internal.annotations.VisibleForTesting; public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { private final ResolverProfileDescriptor[] mItems; - private final boolean mShouldShowNoCrossProfileIntentsEmptyState; private boolean mUseLayoutWithDefault; ResolverMultiProfilePagerAdapter(Context context, ResolverListAdapter adapter, - UserHandle personalProfileUserHandle, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { - super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, + workProfileUserHandle); mItems = new ResolverProfileDescriptor[] { createProfileDescriptor(adapter) }; - mShouldShowNoCrossProfileIntentsEmptyState = true; } ResolverMultiProfilePagerAdapter(Context context, ResolverListAdapter personalAdapter, ResolverListAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, @Profile int defaultProfile, - UserHandle personalProfileUserHandle, - UserHandle workProfileUserHandle, - boolean shouldShowNoCrossProfileIntentsEmptyState) { - super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, + UserHandle workProfileUserHandle) { + super(context, /* currentPage */ defaultProfile, emptyStateProvider, quietModeManager, workProfileUserHandle); mItems = new ResolverProfileDescriptor[] { createProfileDescriptor(personalAdapter), createProfileDescriptor(workAdapter) }; - mShouldShowNoCrossProfileIntentsEmptyState = shouldShowNoCrossProfileIntentsEmptyState; } private ResolverProfileDescriptor createProfileDescriptor( @@ -170,93 +161,6 @@ public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerA return getListViewForIndex(1 - getCurrentPage()); } - @Override - String getMetricsCategory() { - return ResolverActivity.METRICS_CATEGORY_RESOLVER; - } - - @Override - boolean allowShowNoCrossProfileIntentsEmptyState() { - return mShouldShowNoCrossProfileIntentsEmptyState; - } - - @Override - protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, - View.OnClickListener listener) { - showEmptyState(activeListAdapter, - getWorkAppPausedTitle(), - /* subtitle = */ null, - listener); - } - - @Override - protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessWorkMessage()); - } - - @Override - protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { - showEmptyState(activeListAdapter, - getCrossProfileBlockedTitle(), - getCantAccessPersonalMessage()); - } - - @Override - protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, - getNoPersonalAppsAvailableMessage(), - /* subtitle = */ null); - } - - @Override - protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { - showEmptyState(listAdapter, - getNoWorkAppsAvailableMessage(), - /* subtitle= */ null); - } - - private String getWorkAppPausedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PAUSED_TITLE, - () -> getContext().getString(R.string.resolver_turn_on_work_apps)); - } - - private String getCrossProfileBlockedTitle() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - () -> getContext().getString(R.string.resolver_cross_profile_blocked)); - } - - private String getCantAccessWorkMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_WORK, - () -> getContext().getString( - R.string.resolver_cant_access_work_apps_explanation)); - } - - private String getCantAccessPersonalMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_CANT_ACCESS_PERSONAL, - () -> getContext().getString( - R.string.resolver_cant_access_personal_apps_explanation)); - } - - private String getNoWorkAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> getContext().getString( - R.string.resolver_no_work_apps_available)); - } - - private String getNoPersonalAppsAvailableMessage() { - return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> getContext().getString( - R.string.resolver_no_personal_apps_available)); - } - void setUseLayoutWithDefault(boolean useLayoutWithDefault) { mUseLayoutWithDefault = useLayoutWithDefault; } diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..b7c89907 --- /dev/null +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import com.android.internal.R; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final QuietModeManager mQuietModeManager; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull QuietModeManager quietModeManager, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mQuietModeManager = quietModeManager; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle) + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index dd78b69e..5acdb42c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -16,20 +16,24 @@ package com.android.intentresolver; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.os.UserHandle; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; -import java.util.List; import java.util.function.Consumer; import java.util.function.Function; @@ -69,7 +73,10 @@ public class ChooserActivityOverrideData { public UserHandle workProfileUserHandle; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; + public Integer myUserId; + public QuietModeManager mQuietModeManager; + public MyUserIdProvider mMyUserIdProvider; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; public void reset() { @@ -89,14 +96,9 @@ public class ChooserActivityOverrideData { workProfileUserHandle = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; + myUserId = null; packageManager = null; - multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { - @Override - public boolean hasCrossProfileIntents(List intents, int sourceUserId, - int targetUserId) { - return hasCrossProfileIntents; - } - + mQuietModeManager = new QuietModeManager() { @Override public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { return isQuietModeEnabled; @@ -107,8 +109,28 @@ public class ChooserActivityOverrideData { UserHandle workProfileUserHandle) { isQuietModeEnabled = enabled; } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } }; shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); + + mMyUserIdProvider = new MyUserIdProvider() { + @Override + public int getMyUserId() { + return myUserId != null ? myUserId : UserHandle.myUserId(); + } + }; + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 6b74fcd4..56e583bb 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -35,13 +35,10 @@ import android.net.Uri; import android.os.UserHandle; import android.util.Size; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter; -import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ChooserActivityOverrideData; -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; -import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -68,15 +65,6 @@ public class ChooserWrapperActivity return 1234; } - @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, List rList, boolean filterLastUsed) { - AbstractMultiProfilePagerAdapter multiProfilePagerAdapter = - super.createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); - multiProfilePagerAdapter.setInjector(sOverrides.multiPagerAdapterInjector); - return multiProfilePagerAdapter; - } - @Override public ChooserListAdapter createChooserListAdapter(Context context, List payloadIntents, Intent[] initialIntents, List rList, boolean filterLastUsed, @@ -149,6 +137,30 @@ public class ChooserWrapperActivity return super.isVoiceInteraction(); } + @Override + protected MyUserIdProvider createMyUserIdProvider() { + if (sOverrides.mMyUserIdProvider != null) { + return sOverrides.mMyUserIdProvider; + } + return super.createMyUserIdProvider(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected QuietModeManager createQuietModeManager() { + if (sOverrides.mQuietModeManager != null) { + return sOverrides.mQuietModeManager; + } + return super.createQuietModeManager(); + } + @Override public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index b6d63265..7d4b07d8 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -27,6 +29,9 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import java.util.List; @@ -59,12 +64,27 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, List rList, boolean filterLastUsed) { - AbstractMultiProfilePagerAdapter multiProfilePagerAdapter = - super.createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); - multiProfilePagerAdapter.setInjector(sOverrides.multiPagerAdapterInjector); - return multiProfilePagerAdapter; + protected MyUserIdProvider createMyUserIdProvider() { + if (sOverrides.mMyUserIdProvider != null) { + return sOverrides.mMyUserIdProvider; + } + return super.createMyUserIdProvider(); + } + + @Override + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + if (sOverrides.mCrossProfileIntentsChecker != null) { + return sOverrides.mCrossProfileIntentsChecker; + } + return super.createCrossProfileIntentsChecker(); + } + + @Override + protected QuietModeManager createQuietModeManager() { + if (sOverrides.mQuietModeManager != null) { + return sOverrides.mQuietModeManager; + } + return super.createQuietModeManager(); } ResolverWrapperAdapter getAdapter() { @@ -144,9 +164,12 @@ public class ResolverWrapperActivity extends ResolverActivity { public ResolverListController workResolverListController; public Boolean isVoiceInteraction; public UserHandle workProfileUserHandle; + public Integer myUserId; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; + public QuietModeManager mQuietModeManager; + public MyUserIdProvider mMyUserIdProvider; + public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { onSafelyStartCallback = null; @@ -155,15 +178,11 @@ public class ResolverWrapperActivity extends ResolverActivity { resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); workProfileUserHandle = null; + myUserId = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; - multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { - @Override - public boolean hasCrossProfileIntents(List intents, int sourceUserId, - int targetUserId) { - return hasCrossProfileIntents; - } + mQuietModeManager = new QuietModeManager() { @Override public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { return isQuietModeEnabled; @@ -174,7 +193,27 @@ public class ResolverWrapperActivity extends ResolverActivity { UserHandle workProfileUserHandle) { isQuietModeEnabled = enabled; } + + @Override + public void markWorkProfileEnabledBroadcastReceived() { + } + + @Override + public boolean isWaitingToEnableWorkProfile() { + return false; + } }; + + mMyUserIdProvider = new MyUserIdProvider() { + @Override + public int getMyUserId() { + return myUserId != null ? myUserId : UserHandle.myUserId(); + } + }; + + mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); + when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) + .thenAnswer(invocation -> hasCrossProfileIntents); } } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java new file mode 100644 index 00000000..b7eecb3f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -0,0 +1,455 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; +import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; + +import static org.hamcrest.CoreMatchers.not; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.companion.DeviceFilter; +import android.content.Intent; +import android.os.UserHandle; + +import androidx.test.InstrumentationRegistry; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.rule.ActivityTestRule; + +import com.android.internal.R; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@DeviceFilter.MediumType +@RunWith(Parameterized.class) +public class UnbundledChooserActivityWorkProfileTest { + + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); + private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); + + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ChooserWrapperActivity.class, false, + false); + private final TestCase mTestCase; + + public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { + mTestCase = testCase; + } + + @Before + public void cleanOverrideData() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + sOverrides.reset(); + } + + @Test + public void testBlocker() { + setUpPersonalAndWorkComponentInfos(); + sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); + sOverrides.myUserId = mTestCase.getMyUserHandle().getIdentifier(); + + launchActivity(mTestCase.getIsSendAction()); + switchToTab(mTestCase.getTab()); + + switch (mTestCase.getExpectedBlocker()) { + case NO_BLOCKER: + assertNoBlockerDisplayed(); + break; + case PERSONAL_PROFILE_SHARE_BLOCKER: + assertCantSharePersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_SHARE_BLOCKER: + assertCantShareWorkAppsBlockerDisplayed(); + break; + case PERSONAL_PROFILE_ACCESS_BLOCKER: + assertCantAccessPersonalAppsBlockerDisplayed(); + break; + case WORK_PROFILE_ACCESS_BLOCKER: + assertCantAccessWorkAppsBlockerDisplayed(); + break; + } + } + + @Parameterized.Parameters(name = "{0}") + public static Collection tests() { + return Arrays.asList( + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), +// TODO(b/256869196) ChooserActivity goes into requestLayout loop +// new TestCase( +// /* isSendAction= */ true, +// /* hasCrossProfileIntents= */ false, +// /* myUserHandle= */ WORK_USER_HANDLE, +// /* tab= */ WORK, +// /* expectedBlocker= */ NO_BLOCKER +// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), +// TODO(b/256869196) ChooserActivity goes into requestLayout loop +// new TestCase( +// /* isSendAction= */ true, +// /* hasCrossProfileIntents= */ false, +// /* myUserHandle= */ WORK_USER_HANDLE, +// /* tab= */ PERSONAL, +// /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER +// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ true, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ), + new TestCase( + /* isSendAction= */ false, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ PERSONAL_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ NO_BLOCKER + ) + ); + } + + private List createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + } + return infoList; + } + + private List createResolvedComponentsForTest(int numberOfResults) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + return infoList; + } + + private void setUpPersonalAndWorkComponentInfos() { + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_USER_HANDLE.getIdentifier()); + List workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + } + + private void setupResolverControllers( + List personalResolvedComponentInfos, + List workResolvedComponentInfos) { + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); + when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + } + + private void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void markWorkProfileUserAvailable() { + ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE; + } + + private void assertCantAccessWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantAccessPersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantShareWorkAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertCantSharePersonalAppsBlockerDisplayed() { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(isDisplayed())); + onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) + .check(matches(isDisplayed())); + } + + private void assertNoBlockerDisplayed() { + try { + onView(withText(R.string.resolver_cross_profile_blocked)) + .check(matches(not(isDisplayed()))); + } catch (NoMatchingViewException ignored) { + } + } + + private void switchToTab(Tab tab) { + final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab + : R.string.resolver_personal_tab; + + onView(withText(stringId)).perform(click()); + waitForIdle(); + + onView(withId(R.id.contentPanel)) + .perform(swipeUp()); + waitForIdle(); + } + + private Intent createTextIntent(boolean isSendAction) { + Intent sendIntent = new Intent(); + if (isSendAction) { + sendIntent.setAction(Intent.ACTION_SEND); + } + sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); + sendIntent.setType("text/plain"); + return sendIntent; + } + + private void launchActivity(boolean isSendAction) { + Intent sendIntent = createTextIntent(isSendAction); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); + waitForIdle(); + } + + public static class TestCase { + private final boolean mIsSendAction; + private final boolean mHasCrossProfileIntents; + private final UserHandle mMyUserHandle; + private final Tab mTab; + private final ExpectedBlocker mExpectedBlocker; + + public enum ExpectedBlocker { + NO_BLOCKER, + PERSONAL_PROFILE_SHARE_BLOCKER, + WORK_PROFILE_SHARE_BLOCKER, + PERSONAL_PROFILE_ACCESS_BLOCKER, + WORK_PROFILE_ACCESS_BLOCKER + } + + public enum Tab { + WORK, + PERSONAL + } + + public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, + UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { + mIsSendAction = isSendAction; + mHasCrossProfileIntents = hasCrossProfileIntents; + mMyUserHandle = myUserHandle; + mTab = tab; + mExpectedBlocker = expectedBlocker; + } + + public boolean getIsSendAction() { + return mIsSendAction; + } + + public boolean hasCrossProfileIntents() { + return mHasCrossProfileIntents; + } + + public UserHandle getMyUserHandle() { + return mMyUserHandle; + } + + public Tab getTab() { + return mTab; + } + + public ExpectedBlocker getExpectedBlocker() { + return mExpectedBlocker; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder("test"); + + if (mTab == WORK) { + result.append("WorkTab_"); + } else { + result.append("PersonalTab_"); + } + + if (mIsSendAction) { + result.append("sendAction_"); + } else { + result.append("notSendAction_"); + } + + if (mHasCrossProfileIntents) { + result.append("hasCrossProfileIntents_"); + } else { + result.append("doesNotHaveCrossProfileIntents_"); + } + + if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { + result.append("myUserIsPersonal_"); + } else { + result.append("myUserIsWork_"); + } + + if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { + result.append("thenNoBlocker"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnPersonalProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { + result.append("thenAccessBlockerOnWorkProfile"); + } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { + result.append("thenShareBlockerOnWorkProfile"); + } + + return result.toString(); + } + } +} -- cgit v1.2.3-59-g8ed1b From 71342b3d9f6f64c3c2c5b7a2b4ebb244ad983964 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 15 Nov 2022 13:57:38 -0800 Subject: Fix work profile record initialization. Change the work pforifle field into a lazy initialization delegate; use it as a single source for the work profile user handle value to avoid getWorkProfileUserHandle/fetchWorkProfileUserHandle confusion. Test: manual test Test: atest IntentResolverUnitTests Change-Id: I66e8b22dc7e7f355d964593f383d6a7c5d5827c3 --- .../android/intentresolver/ResolverActivity.java | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index fece8d3d..91d3cc7f 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -107,7 +107,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWor import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.chooser.ChooserTargetInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -123,6 +122,7 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** * This activity is displayed when the system attempts to start an Intent for @@ -223,7 +223,11 @@ public class ResolverActivity extends FragmentActivity implements private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; - private UserHandle mWorkProfileUserHandle; + private Supplier mLazyWorkProfileUserHandle = () -> { + final UserHandle result = fetchWorkProfileUserProfile(); + mLazyWorkProfileUserHandle = () -> result; + return result; + }; @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; @@ -408,7 +412,6 @@ public class ResolverActivity extends FragmentActivity implements mDefaultTitleResId = defaultTitleRes; mSupportsAlwaysUseOption = supportsAlwaysUseOption; - mWorkProfileUserHandle = fetchWorkProfileUserProfile(); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -699,19 +702,25 @@ public class ResolverActivity extends FragmentActivity implements protected UserHandle getPersonalProfileUserHandle() { return UserHandle.of(ActivityManager.getCurrentUser()); } - protected @Nullable UserHandle getWorkProfileUserHandle() { - return mWorkProfileUserHandle; + + @Nullable + protected UserHandle getWorkProfileUserHandle() { + return mLazyWorkProfileUserHandle.get(); } - protected @Nullable UserHandle fetchWorkProfileUserProfile() { - mWorkProfileUserHandle = null; + @Nullable + private UserHandle fetchWorkProfileUserProfile() { UserManager userManager = getSystemService(UserManager.class); + if (userManager == null) { + return null; + } + UserHandle result = null; for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) { if (userInfo.isManagedProfile()) { - mWorkProfileUserHandle = userInfo.getUserHandle(); + result = userInfo.getUserHandle(); } } - return mWorkProfileUserHandle; + return result; } private boolean hasWorkProfile() { -- cgit v1.2.3-59-g8ed1b From 2a6323fd89e50d655c061b6dda2d2f7ff11493b5 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 11 Nov 2022 15:27:08 -0500 Subject: Extact Chooser intent processing; remove delegate This CL makes two small cleanups with no observable behavior changes: 1. Extract a helper (in a new component) to handle the intent processing that was previously inline in ChooserActivity's onCreate(), storing the validated request data immutably. There's a lot of boilerplate in the processing that was too verbose inline in such an important lifecycle hook, and this refactoring clarifies the lifecycle -- many of the extracted parameters had been individual ivars on ChooserActivity, and it wasn't obvious that they were all assigned in a batch and then never modified. 2. In previous code reviews, ayepin@ and I have discussed the opportunity to remove the ChooserListCommunicator interface now that it's clearly only responsible for providing values that we can precompute and inject upfront; that precomputation mostly involves the parameters extracted in this CL, so I've gone ahead and made that change here. (It's not as straightforward to replace the base ResolverListCommunicator, so for now we're still passing through the same "communicator object" as before, but without the enhanced "chooser-specific" API. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I7c3d47d282591f86ab1fae5e04c3d7d83f2fea0d --- .../android/intentresolver/ChooserActivity.java | 305 +++++--------- .../android/intentresolver/ChooserListAdapter.java | 68 ++-- .../intentresolver/ChooserRequestParameters.java | 441 +++++++++++++++++++++ .../intentresolver/ChooserListAdapterTest.kt | 8 +- .../intentresolver/ChooserWrapperActivity.java | 16 +- 5 files changed, 589 insertions(+), 249 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserRequestParameters.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 776d34a9..89a9833f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -133,7 +133,6 @@ import java.io.File; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; import java.util.Arrays; @@ -154,13 +153,9 @@ import java.util.function.Supplier; * */ public class ChooserActivity extends ResolverActivity implements - ChooserListAdapter.ChooserListCommunicator { + ResolverListAdapter.ResolverListCommunicator { private static final String TAG = "ChooserActivity"; - private boolean mShouldDisplayLandscape; - - public ChooserActivity() { - } /** * Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself * in onStop when launched in a new task. If this extra is set to true, we do not finish @@ -169,7 +164,6 @@ public class ChooserActivity extends ResolverActivity implements public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP = "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP"; - /** * Transition name for the first image preview. * To be used for shared element transition into this activity. @@ -216,9 +210,6 @@ public class ChooserActivity extends ResolverActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - // statsd logger wrapper - protected ChooserActivityLogger mChooserActivityLogger; - @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { TARGET_TYPE_DEFAULT, TARGET_TYPE_CHOOSER_TARGET, @@ -246,14 +237,26 @@ public class ChooserActivity extends ResolverActivity implements | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - private Bundle mReplacementExtras; - private IntentSender mChosenComponentSender; - private IntentSender mRefinementIntentSender; - private RefinementResultReceiver mRefinementResultReceiver; - private ChooserTarget[] mCallerChooserTargets; - private ArrayList mFilteredComponentNames; + /* TODO: this is `nullable` *primarily* because we have to defer the assignment til onCreate(). + * We make the only assignment there, and *expect* it to be ready by the time we ever use it -- + * someday if we move all the usage to a component with a narrower lifecycle (something that + * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we + * should be able to make this assignment as "final." Unfortunately, for now we also have + * a vestigial design where ChooserActivity.onCreate() can invalidate a request, but it still + * has to call up to ResolverActivity.onCreate() before closing, and the base method delegates + * back down to other methods in ChooserActivity that aren't really relevant if we're closing + * (and so they'd normally want to assume it was a valid "creation," with non-null parameters). + * Any client null checks are workarounds for this condition that can be removed once that + * design is cleaned up. */ + @Nullable + private ChooserRequestParameters mChooserRequest; - private Intent mReferrerFillInIntent; + private boolean mShouldDisplayLandscape; + // statsd logger wrapper + protected ChooserActivityLogger mChooserActivityLogger; + + @Nullable + private RefinementResultReceiver mRefinementResultReceiver; private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -265,6 +268,7 @@ public class ChooserActivity extends ResolverActivity implements private static final int MAX_LOG_RANK_POSITION = 12; + // TODO: are these used anywhere? They should probably be migrated to ChooserRequestParameters. private static final int MAX_EXTRA_INITIAL_INTENTS = 2; private static final int MAX_EXTRA_CHOOSER_TARGETS = 2; @@ -291,6 +295,8 @@ public class ChooserActivity extends ResolverActivity implements private final SparseArray mProfileRecords = new SparseArray<>(); + public ChooserActivity() {} + private void setupPreDrawForSharedElementTransition(View v) { v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override @@ -319,134 +325,42 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetTriggered(); - mIsSuccessfullySelected = false; - Intent intent = getIntent(); - Parcelable targetParcelable = intent.getParcelableExtra(Intent.EXTRA_INTENT); - if (targetParcelable instanceof Uri) { - try { - targetParcelable = Intent.parseUri(targetParcelable.toString(), - Intent.URI_INTENT_SCHEME); - } catch (URISyntaxException ex) { - // doesn't parse as an intent; let the next test fail and error out - } - } - - if (!(targetParcelable instanceof Intent)) { - Log.w("ChooserActivity", "Target is not an intent: " + targetParcelable); + try { + mChooserRequest = new ChooserRequestParameters( + getIntent(), getReferrer(), getNearbySharingComponent()); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); super.onCreate(null); return; } - final Intent target = (Intent) targetParcelable; - modifyTargetIntent(target); - Parcelable[] targetsParcelable - = intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS); - if (targetsParcelable != null) { - Intent[] additionalTargets = new Intent[targetsParcelable.length]; - for (int i = 0; i < targetsParcelable.length; i++) { - if (!(targetsParcelable[i] instanceof Intent)) { - Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i - + " is not an Intent: " + targetsParcelable[i]); - finish(); - super.onCreate(null); - return; - } - final Intent additionalTarget = (Intent) targetsParcelable[i]; - additionalTargets[i] = additionalTarget; - modifyTargetIntent(additionalTarget); - } - setAdditionalTargets(additionalTargets); - } - mReplacementExtras = intent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); + setAdditionalTargets(mChooserRequest.getAdditionalTargets()); - // Do not allow the title to be changed when sharing content - CharSequence title = null; - if (!isSendAction(target)) { - title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE); - } else { - Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" - + " preview title by using EXTRA_TITLE property of the wrapped" - + " EXTRA_INTENT."); - } - - int defaultTitleRes = 0; - if (title == null) { - defaultTitleRes = com.android.internal.R.string.chooseActivity; - } - - Parcelable[] pa = intent.getParcelableArrayExtra(Intent.EXTRA_INITIAL_INTENTS); - Intent[] initialIntents = null; - if (pa != null) { - int count = Math.min(pa.length, MAX_EXTRA_INITIAL_INTENTS); - initialIntents = new Intent[count]; - for (int i = 0; i < count; i++) { - if (!(pa[i] instanceof Intent)) { - Log.w(TAG, "Initial intent #" + i + " not an Intent: " + pa[i]); - finish(); - super.onCreate(null); - return; - } - final Intent in = (Intent) pa[i]; - modifyTargetIntent(in); - initialIntents[i] = in; - } - } - - mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, getReferrer()); - - mChosenComponentSender = intent.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); - mRefinementIntentSender = intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); setSafeForwardingMode(true); mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mFilteredComponentNames = new ArrayList<>(); - try { - ComponentName[] exclodedComponents = intent.getParcelableArrayExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, - ComponentName.class); - if (exclodedComponents != null) { - Collections.addAll(mFilteredComponentNames, exclodedComponents); - } - } catch (ClassCastException e) { - Log.e(TAG, "Excluded components must be of type ComponentName[]", e); - } - - // Exclude Nearby from main list if chip is present, to avoid duplication - ComponentName nearby = getNearbySharingComponent(); - if (nearby != null) { - mFilteredComponentNames.add(nearby); - } - - pa = intent.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_TARGETS); - if (pa != null) { - int count = Math.min(pa.length, MAX_EXTRA_CHOOSER_TARGETS); - ChooserTarget[] targets = new ChooserTarget[count]; - for (int i = 0; i < count; i++) { - if (!(pa[i] instanceof ChooserTarget)) { - Log.w(TAG, "Chooser target #" + i + " not a ChooserTarget: " + pa[i]); - targets = null; - break; - } - targets[i] = (ChooserTarget) pa[i]; - } - mCallerChooserTargets = targets; - } - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); - IntentFilter targetIntentFilter = getTargetIntentFilter(target); + setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + createProfileRecords( new AppPredictorFactory( getApplicationContext(), - target.getStringExtra(Intent.EXTRA_TEXT), - targetIntentFilter), - targetIntentFilter); + mChooserRequest.getSharedText(), + mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter()); + + super.onCreate( + savedInstanceState, + mChooserRequest.getTargetIntent(), + mChooserRequest.getTitle(), + mChooserRequest.getDefaultTitleResource(), + mChooserRequest.getInitialIntents(), + /* rList: List = */ null, + /* supportsAlwaysUseOption = */ false); mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, @@ -454,23 +368,21 @@ public class ChooserActivity extends ResolverActivity implements this::hideContentPreview, this::setupPreDrawForSharedElementTransition); - super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, - null, false); - mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) .setSubtype(isWorkProfile() ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE) - .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, target.getType()) + .addTaggedData( + MetricsEvent.FIELD_SHARESHEET_MIMETYPE, mChooserRequest.getTargetType()) .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); // expand/shrink direct share 4 -> 8 viewgroup - if (isSendAction(target)) { + if (mChooserRequest.isSendActionTarget()) { mResolverDrawerLayout.setOnScrollChangeListener(this::handleScroll); } @@ -499,13 +411,14 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logShareStarted( FrameworkStatsLog.SHARESHEET_STARTED, getReferrerPackageName(), - target.getType(), - mCallerChooserTargets == null ? 0 : mCallerChooserTargets.length, - initialIntents == null ? 0 : initialIntents.length, + mChooserRequest.getTargetType(), + mChooserRequest.getCallerChooserTargets().size(), + (mChooserRequest.getInitialIntents() == null) + ? 0 : mChooserRequest.getInitialIntents().length, isWorkProfile(), ChooserContentPreviewUi.findPreferredContentPreview( getTargetIntent(), getContentResolver(), this::isImageType), - target.getAction() + mChooserRequest.getTargetAction() ); setEnterSharedElementCallback(new SharedElementCallback() { @@ -607,7 +520,7 @@ public class ChooserActivity extends ResolverActivity implements @Override protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = isSendAction(getTargetIntent()); + final boolean isSendAction = mChooserRequest.isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -1194,9 +1107,14 @@ public class ChooserActivity extends ResolverActivity implements @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + if (mChooserRequest == null) { + return defIntent; + } + Intent result = defIntent; - if (mReplacementExtras != null) { - final Bundle replExtras = mReplacementExtras.getBundle(aInfo.packageName); + if (mChooserRequest.getReplacementExtras() != null) { + final Bundle replExtras = + mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -1217,12 +1135,13 @@ public class ChooserActivity extends ResolverActivity implements @Override public void onActivityStarted(TargetInfo cti) { - if (mChosenComponentSender != null) { + if (mChooserRequest.getChosenComponentSender() != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); try { - mChosenComponentSender.sendIntent(this, Activity.RESULT_OK, fillIn, null, null); + mChooserRequest.getChosenComponentSender().sendIntent( + this, Activity.RESULT_OK, fillIn, null, null); } catch (IntentSender.SendIntentException e) { Slog.e(TAG, "Unable to launch supplied IntentSender to report " + "the chosen component: " + e); @@ -1233,10 +1152,14 @@ public class ChooserActivity extends ResolverActivity implements @Override public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - if (mCallerChooserTargets != null && mCallerChooserTargets.length > 0) { + if (mChooserRequest == null) { + return; + } + + if (mChooserRequest.getCallerChooserTargets().size() > 0) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - Lists.newArrayList(mCallerChooserTargets), + mChooserRequest.getCallerChooserTargets(), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -1277,8 +1200,8 @@ public class ChooserActivity extends ResolverActivity implements // TODO: implement these type-conditioned behaviors polymorphically, and consider moving // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter = - targetInfo.isSelectableTargetInfo() ? getTargetIntentFilter() : null; + IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() + ? mChooserRequest.getTargetIntentFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -1293,16 +1216,9 @@ public class ChooserActivity extends ResolverActivity implements intentFilter); } - private void modifyTargetIntent(Intent in) { - if (isSendAction(in)) { - in.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | - Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - } - } - @Override protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { - if (mRefinementIntentSender != null) { + if (mChooserRequest.getRefinementIntentSender() != null) { final Intent fillIn = new Intent(); final List sourceIntents = target.getAllSourceIntents(); if (!sourceIntents.isEmpty()) { @@ -1321,7 +1237,8 @@ public class ChooserActivity extends ResolverActivity implements fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, mRefinementResultReceiver); try { - mRefinementIntentSender.sendIntent(this, 0, fillIn, null, null); + mChooserRequest.getRefinementIntentSender().sendIntent( + this, 0, fillIn, null, null); return false; } catch (SendIntentException e) { Log.e(TAG, "Refinement IntentSender failed to send", e); @@ -1372,9 +1289,7 @@ public class ChooserActivity extends ResolverActivity implements directTargetHashed = targetInfo.getHashedTargetIdForMetrics(this); directTargetAlsoRanked = getRankedPosition(targetInfo); - if (mCallerChooserTargets != null) { - numCallerProvided = mCallerChooserTargets.length; - } + numCallerProvided = mChooserRequest.getCallerChooserTargets().size(); getChooserActivityLogger().logShareTargetSelected( SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, @@ -1686,7 +1601,7 @@ public class ChooserActivity extends ResolverActivity implements @Override boolean isComponentFiltered(ComponentName name) { - return mFilteredComponentNames != null && mFilteredComponentNames.contains(name); + return mChooserRequest.getFilteredComponentNames().contains(name); } @Override @@ -1696,19 +1611,35 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter(Context context, - List payloadIntents, Intent[] initialIntents, List rList, - boolean filterLastUsed, UserHandle userHandle) { - ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, - initialIntents, rList, filterLastUsed, - createListController(userHandle)); + public ChooserGridAdapter createChooserGridAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + UserHandle userHandle) { + ChooserListAdapter chooserListAdapter = createChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + mChooserRequest, + mMaxTargetsPerRow); return new ChooserGridAdapter(chooserListAdapter); } @VisibleForTesting - public ChooserListAdapter createChooserListAdapter(Context context, - List payloadIntents, Intent[] initialIntents, List rList, - boolean filterLastUsed, ResolverListController resolverListController) { + public ChooserListAdapter createChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow) { return new ChooserListAdapter( context, payloadIntents, @@ -1718,7 +1649,9 @@ public class ChooserActivity extends ResolverActivity implements resolverListController, this, context.getPackageManager(), - getChooserActivityLogger()); + getChooserActivityLogger(), + chooserRequest, + maxTargetsPerRow); } @VisibleForTesting @@ -1949,16 +1882,6 @@ public class ChooserActivity extends ResolverActivity implements super.onHandlePackagesChanged(listAdapter); } - @Override // SelectableTargetInfoCommunicator - public Intent getReferrerFillInIntent() { - return mReferrerFillInIntent; - } - - @Override // ChooserListCommunicator - public int getMaxRankedTargets() { - return mMaxTargetsPerRow; - } - @Override public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); @@ -2093,24 +2016,6 @@ public class ChooserActivity extends ResolverActivity implements }); } - @Override // ChooserListCommunicator - public boolean isSendAction(Intent targetIntent) { - if (targetIntent == null) { - return false; - } - - String action = targetIntent.getAction(); - if (action == null) { - return false; - } - - if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { - return true; - } - - return false; - } - /** * The sticky content preview is shown only when we have a tabbed view. It's shown above * the tabs so it is not part of the scrollable list. If we are not in tabbed view, @@ -2132,7 +2037,7 @@ public class ChooserActivity extends ResolverActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - return isSendAction(getTargetIntent()); + return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -2762,7 +2667,7 @@ public class ChooserActivity extends ResolverActivity implements position -= getSystemRowCount() + getProfileRowCount(); final int serviceCount = mChooserListAdapter.getServiceTargetCount(); - final int serviceRows = (int) Math.ceil((float) serviceCount / getMaxRankedTargets()); + final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); if (position < serviceRows) { return position * mMaxTargetsPerRow; } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index e31bf2ab..b18d2718 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -83,7 +83,9 @@ public class ChooserListAdapter extends ResolverListAdapter { /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private final ChooserListCommunicator mChooserListCommunicator; + private final ChooserRequestParameters mChooserRequest; + private final int mMaxRankedTargets; + private final ChooserActivityLogger mChooserActivityLogger; private final Map mIconLoaders = new HashMap<>(); @@ -136,15 +138,19 @@ public class ChooserListAdapter extends ResolverListAdapter { List rList, boolean filterLastUsed, ResolverListController resolverListController, - ChooserListCommunicator chooserListCommunicator, + ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, - ChooserActivityLogger chooserActivityLogger) { + ChooserActivityLogger chooserActivityLogger, + ChooserRequestParameters chooserRequest, + int maxRankedTargets) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super(context, payloadIntents, null, rList, filterLastUsed, - resolverListController, chooserListCommunicator, false); + resolverListController, resolverListCommunicator, false); + + mChooserRequest = chooserRequest; + mMaxRankedTargets = maxRankedTargets; - mChooserListCommunicator = chooserListCommunicator; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); createPlaceHolders(); mChooserActivityLogger = chooserActivityLogger; @@ -221,13 +227,13 @@ public class ChooserListAdapter extends ResolverListAdapter { Log.d(TAG, "clearing queryTargets on package change"); } createPlaceHolders(); - mChooserListCommunicator.onHandlePackagesChanged(this); + mResolverListCommunicator.onHandlePackagesChanged(this); } private void createPlaceHolders() { mServiceTargets.clear(); - for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { + for (int i = 0; i < mMaxRankedTargets; ++i) { mServiceTargets.add(mPlaceHolderTargetInfo); } } @@ -367,8 +373,9 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override public int getUnfilteredCount() { int appTargets = super.getUnfilteredCount(); - if (appTargets > mChooserListCommunicator.getMaxRankedTargets()) { - appTargets = appTargets + mChooserListCommunicator.getMaxRankedTargets(); + if (appTargets > mMaxRankedTargets) { + // TODO: what does this condition mean? + appTargets = appTargets + mMaxRankedTargets; } return appTargets + getSelectableServiceTargetCount() + getCallerTargetCount(); } @@ -392,9 +399,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } public int getServiceTargetCount() { - if (mChooserListCommunicator.isSendAction(mChooserListCommunicator.getTargetIntent()) - && !ActivityManager.isLowRamDeviceStatic()) { - return Math.min(mServiceTargets.size(), mChooserListCommunicator.getMaxRankedTargets()); + if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) { + return Math.min(mServiceTargets.size(), mMaxRankedTargets); } return 0; @@ -403,15 +409,14 @@ public class ChooserListAdapter extends ResolverListAdapter { int getAlphaTargetCount() { int groupedCount = mSortedList.size(); int ungroupedCount = mCallerTargets.size() + mDisplayList.size(); - return ungroupedCount > mChooserListCommunicator.getMaxRankedTargets() ? groupedCount : 0; + return (ungroupedCount > mMaxRankedTargets) ? groupedCount : 0; } /** * Fetch ranked app target count */ public int getRankedTargetCount() { - int spacesAvailable = - mChooserListCommunicator.getMaxRankedTargets() - getCallerTargetCount(); + int spacesAvailable = mMaxRankedTargets - getCallerTargetCount(); return Math.min(spacesAvailable, super.getCount()); } @@ -508,8 +513,8 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator - .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { + if (mResolverListCommunicator.resolveInfoMatch( + dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } } @@ -520,9 +525,8 @@ public class ChooserListAdapter extends ResolverListAdapter { * Fetch surfaced direct share target info */ public List getSurfacedTargetInfo() { - int maxSurfacedTargets = mChooserListCommunicator.getMaxRankedTargets(); return mServiceTargets.subList(0, - Math.min(maxSurfacedTargets, getSelectableServiceTargetCount())); + Math.min(mMaxRankedTargets, getSelectableServiceTargetCount())); } @@ -550,9 +554,9 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mChooserListCommunicator.getTargetIntent(), - mChooserListCommunicator.getReferrerFillInIntent(), - mChooserListCommunicator.getMaxRankedTargets(), + mChooserRequest.getTargetIntent(), + mChooserRequest.getReferrerFillInIntent(), + mMaxRankedTargets, mServiceTargets); if (isUpdated) { notifyDataSetChanged(); @@ -616,8 +620,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected List doInBackground( List... params) { Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], - mChooserListCommunicator.getMaxRankedTargets()); + mResolverListController.topK(params[0], mMaxRankedTargets); Trace.endSection(); return params[0]; } @@ -625,28 +628,13 @@ public class ChooserListAdapter extends ResolverListAdapter { protected void onPostExecute(List sortedComponents) { processSortedList(sortedComponents, doPostProcessing); if (doPostProcessing) { - mChooserListCommunicator.updateProfileViewButton(); + mResolverListCommunicator.updateProfileViewButton(); notifyDataSetChanged(); } } }; } - /** - * Necessary methods to communicate between {@link ChooserListAdapter} - * and {@link ChooserActivity}. - */ - interface ChooserListCommunicator extends ResolverListCommunicator { - - int getMaxRankedTargets(); - - boolean isSendAction(Intent targetIntent); - - Intent getTargetIntent(); - - Intent getReferrerFillInIntent(); - } - /** * Loads direct share targets icons. */ diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java new file mode 100644 index 00000000..81481bf1 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -0,0 +1,441 @@ +/* + * Copyright (C) 2008 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.intentresolver; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.os.PatternMatcher; +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; + +import com.google.common.collect.ImmutableList; + +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched + * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars. + * + * TODO: field nullability in this class reflects legacy use, and typically would indicate that the + * client's intent didn't provide the respective data. In some cases we may be able to provide + * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the + * client code could instead handle empty collections equally well. + * + * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to + * it internally) differ from the legacy model because they're computed directly from the initial + * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved + * through methods on the base class. The base always seems to return them exactly as they were + * provided, so this should be safe -- and clients can reasonably switch to retrieving through these + * parameters instead. For now, the other convention is still used in some places. Ideally we'd like + * to normalize on a single source of truth, but we'll have to clean up the delegation up to the + * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?). + */ +public class ChooserRequestParameters { + private static final String TAG = "ChooserActivity"; + + private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = + Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; + + private final Intent mTarget; + private final Pair mTitleSpec; + private final Intent mReferrerFillInIntent; + private final ImmutableList mFilteredComponentNames; + private final ImmutableList mCallerChooserTargets; + private final boolean mRetainInOnStop; + + @Nullable + private final ImmutableList mAdditionalTargets; + + @Nullable + private final Bundle mReplacementExtras; + + @Nullable + private final ImmutableList mInitialIntents; + + @Nullable + private final IntentSender mChosenComponentSender; + + @Nullable + private final IntentSender mRefinementIntentSender; + + @Nullable + private final String mSharedText; + + @Nullable + private final IntentFilter mTargetIntentFilter; + + public ChooserRequestParameters( + final Intent clientIntent, + final Uri referrer, + @Nullable final ComponentName nearbySharingComponent) { + final Intent requestedTarget = parseTargetIntentExtra( + clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); + mTarget = intentWithModifiedLaunchFlags(requestedTarget); + + mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( + clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); + + mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); + + mTitleSpec = makeTitleSpec( + clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE), + isSendAction(mTarget.getAction())); + + mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent( + clientIntent, Intent.EXTRA_INITIAL_INTENTS); + + mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); + + mChosenComponentSender = clientIntent.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mRefinementIntentSender = clientIntent.getParcelableExtra( + Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); + + mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent); + + mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); + + mRetainInOnStop = clientIntent.getBooleanExtra( + ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false); + + mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); + + mTargetIntentFilter = getTargetIntentFilter(mTarget); + } + + public Intent getTargetIntent() { + return mTarget; + } + + @Nullable + public String getTargetAction() { + return getTargetIntent().getAction(); + } + + public boolean isSendActionTarget() { + return isSendAction(getTargetAction()); + } + + @Nullable + public String getTargetType() { + return getTargetIntent().getType(); + } + + @Nullable + public CharSequence getTitle() { + return mTitleSpec.first; + } + + public int getDefaultTitleResource() { + return mTitleSpec.second; + } + + public Intent getReferrerFillInIntent() { + return mReferrerFillInIntent; + } + + public ImmutableList getFilteredComponentNames() { + return mFilteredComponentNames; + } + + public ImmutableList getCallerChooserTargets() { + return mCallerChooserTargets; + } + + /** + * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. + */ + public boolean shouldRetainInOnStop() { + return mRetainInOnStop; + } + + /** + * TODO: this returns a nullable array for convenience, but if the legacy APIs can be + * refactored, returning {@link mAdditionalTargets} directly is simpler and safer. + */ + @Nullable + public Intent[] getAdditionalTargets() { + return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]); + } + + @Nullable + public Bundle getReplacementExtras() { + return mReplacementExtras; + } + + /** + * TODO: this returns a nullable array for convenience, but if the legacy APIs can be + * refactored, returning {@link mInitialIntents} directly is simpler and safer. + */ + @Nullable + public Intent[] getInitialIntents() { + return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]); + } + + @Nullable + public IntentSender getChosenComponentSender() { + return mChosenComponentSender; + } + + @Nullable + public IntentSender getRefinementIntentSender() { + return mRefinementIntentSender; + } + + @Nullable + public String getSharedText() { + return mSharedText; + } + + @Nullable + public IntentFilter getTargetIntentFilter() { + return mTargetIntentFilter; + } + + private static boolean isSendAction(@Nullable String action) { + return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); + } + + private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) { + if (targetParcelable instanceof Uri) { + try { + targetParcelable = Intent.parseUri(targetParcelable.toString(), + Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex); + } + } + + if (!(targetParcelable instanceof Intent)) { + throw new IllegalArgumentException( + "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable); + } + + return ((Intent) targetParcelable); + } + + private static Intent intentWithModifiedLaunchFlags(Intent intent) { + if (isSendAction(intent.getAction())) { + intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION); + } + return intent; + } + + /** + * Build a pair of values specifying the title to use from the client request. The first + * ({@link CharSequence}) value is the client-specified title, if there was one and their + * requested target wasn't a send action; otherwise it is null. The second value is + * the resource ID of a default title string; this is nonzero only if the first value is null. + * + * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or + * create a real type (not {@link Pair}) to express the semantics described in this comment. + */ + private static Pair makeTitleSpec( + @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) { + if (hasSendActionTarget && (requestedTitle != null)) { + // Do not allow the title to be changed when sharing content + Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" + + " preview title by using EXTRA_TITLE property of the wrapped" + + " EXTRA_INTENT."); + requestedTitle = null; + } + + int defaultTitleRes = + (requestedTitle == null) ? com.android.internal.R.string.chooseActivity : 0; + + return Pair.create(requestedTitle, defaultTitleRes); + } + + private static ImmutableList getFilteredComponentNames( + Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { + Stream filteredComponents = streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); + + if (nearbySharingComponent != null) { + // Exclude Nearby from main list if chip is present, to avoid duplication. + // TODO: we don't have an explicit guarantee that the chip will be displayed just + // because we have a non-null component; that's ultimately determined by the preview + // layout. Maybe we can make that decision further upstream? + filteredComponents = Stream.concat( + filteredComponents, Stream.of(nearbySharingComponent)); + } + + return filteredComponents.collect(toImmutableList()); + } + + private static ImmutableList parseCallerTargetsFromClientIntent( + Intent clientIntent) { + return + streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true) + .collect(toImmutableList()); + } + + private static Collector> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } + + @Nullable + private static ImmutableList intentsWithModifiedLaunchFlagsFromExtraIfPresent( + Intent clientIntent, String extra) { + Stream intents = + streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false); + if (intents == null) { + return null; + } + return intents + .map(ChooserRequestParameters::intentWithModifiedLaunchFlags) + .collect(toImmutableList()); + } + + /** + * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent} + * as the optional parcelable array extra with key {@code extra}. The stream elements, if any, + * are all of the type specified by {@code clazz}. + * + * @param intent The intent that may contain the optional extras. + * @param extra The extras key to identify the parcelable array. + * @param clazz A class that is assignable from any elements in the result stream. + * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have + * the required type. If false, throw an {@link IllegalArgumentException} if the extra is + * non-null but can't be assigned to variables of type {@code T}. + * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't + * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true). + * If false, return null in these cases, and only return an empty stream if the intent + * explicitly provided an empty array for the specified extra. + */ + @Nullable + private static Stream streamParcelableArrayExtra( + final Intent intent, + String extra, + @NonNull Class clazz, + boolean warnOnTypeError, + boolean streamEmptyIfNull) { + T[] result = null; + + try { + result = getParcelableArrayExtraIfPresent(intent, extra, clazz); + } catch (IllegalArgumentException e) { + if (warnOnTypeError) { + Log.w(TAG, "Ignoring client-requested " + extra, e); + } else { + throw e; + } + } + + if (result != null) { + return Arrays.stream(result); + } else if (streamEmptyIfNull) { + return Stream.empty(); + } else { + return null; + } + } + + /** + * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]} + * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't + * present in the {@code intent}, return null. + */ + @Nullable + private static T[] getParcelableArrayExtraIfPresent( + final Intent intent, String extra, @NonNull Class clazz) throws + IllegalArgumentException { + if (!intent.hasExtra(extra)) { + return null; + } + + T[] castResult = intent.getParcelableArrayExtra(extra, clazz); + if (castResult == null) { + Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra); + if (actualExtrasArray != null) { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s[]: %s", + extra, + clazz.getSimpleName(), + Arrays.toString(actualExtrasArray))); + } else if (intent.getParcelableExtra(extra) != null) { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s[] (or any array type): %s", + extra, + clazz.getSimpleName(), + intent.getParcelableExtra(extra))); + } else { + throw new IllegalArgumentException( + String.format( + "%s is not of type %s (or any Parcelable type): %s", + extra, + clazz.getSimpleName(), + intent.getExtras().get(extra))); + } + } + + return castResult; + } + + private static IntentFilter getTargetIntentFilter(final Intent intent) { + try { + String dataString = intent.getDataString(); + if (intent.getType() == null) { + if (!TextUtils.isEmpty(dataString)) { + return new IntentFilter(intent.getAction(), dataString); + } + Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); + return null; + } + IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); + List contentUris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(intent.getAction())) { + Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + contentUris.add(uri); + } + } else { + List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris != null) { + contentUris.addAll(uris); + } + } + for (Uri uri : contentUris) { + intentFilter.addDataScheme(uri.getScheme()); + intentFilter.addDataAuthority(uri.getAuthority(), null); + intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); + } + return intentFilter; + } catch (Exception e) { + Log.e(TAG, "Failed to get target intent filter", e); + return null; + } + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index bcb6c240..d054e7fa 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -45,10 +45,6 @@ class ChooserListAdapterTest { } private val context = InstrumentationRegistry.getInstrumentation().getContext() private val resolverListController = mock() - private val chooserListCommunicator = mock { - whenever(maxRankedTargets).thenReturn(0) - whenever(targetIntent).thenReturn(mock()) - } private val chooserActivityLogger = mock() private fun createChooserListAdapter( @@ -60,9 +56,11 @@ class ChooserListAdapterTest { emptyList(), false, resolverListController, - chooserListCommunicator, + mock(), packageManager, chooserActivityLogger, + mock(), + 0 ) { override fun createLoadDirectShareIconTask( info: SelectableTargetInfo diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 56e583bb..fe448d63 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -66,9 +66,15 @@ public class ChooserWrapperActivity } @Override - public ChooserListAdapter createChooserListAdapter(Context context, List payloadIntents, - Intent[] initialIntents, List rList, boolean filterLastUsed, - ResolverListController resolverListController) { + public ChooserListAdapter createChooserListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + ResolverListController resolverListController, + ChooserRequestParameters chooserRequest, + int maxTargetsPerRow) { PackageManager packageManager = sOverrides.packageManager == null ? context.getPackageManager() : sOverrides.packageManager; @@ -81,7 +87,9 @@ public class ChooserWrapperActivity resolverListController, this, packageManager, - getChooserActivityLogger()); + getChooserActivityLogger(), + chooserRequest, + maxTargetsPerRow); } @Override -- cgit v1.2.3-59-g8ed1b From ebe0fe5ad0528f2f6e90ee24e3041054fac1ed22 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 28 Nov 2022 11:35:33 -0800 Subject: Synchronize ResolverDrawerLayout with core Copy over new changes from frameworks/base ResolverDrawerLayout. Copied CLs: - ag/19719026: (resolver_list.xml change is not picked). Test: manual tests Test: atest IntentResolverUnitTests Change-Id: I6a22abd211932511de236156a62b2096593cf020 --- java/res/values/attrs.xml | 6 +++ .../widget/ResolverDrawerLayout.java | 60 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index 3ec7c2f3..2f2bbda2 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -26,6 +26,12 @@ + + diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index 29821e66..a2c5afc6 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -17,6 +17,9 @@ package com.android.intentresolver.widget; +import static android.content.res.Resources.ID_NULL; + +import android.annotation.IdRes; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; @@ -98,6 +101,8 @@ public class ResolverDrawerLayout extends ViewGroup { private int mTopOffset; private boolean mShowAtTop; + @IdRes + private int mIgnoreOffsetTopLimitViewId = ID_NULL; private boolean mIsDragging; private boolean mOpenOnClick; @@ -158,6 +163,10 @@ public class ResolverDrawerLayout extends ViewGroup { mIsMaxCollapsedHeightSmallExplicit = a.hasValue(R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall); mShowAtTop = a.getBoolean(R.styleable.ResolverDrawerLayout_showAtTop, false); + if (a.hasValue(R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit)) { + mIgnoreOffsetTopLimitViewId = a.getResourceId( + R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); + } a.recycle(); mScrollIndicatorDrawable = mContext.getDrawable( @@ -580,12 +589,32 @@ public class ResolverDrawerLayout extends ViewGroup { dy -= 1.0f; } + boolean isIgnoreOffsetLimitSet = false; + int ignoreOffsetLimit = 0; + View ignoreOffsetLimitView = findIgnoreOffsetLimitView(); + if (ignoreOffsetLimitView != null) { + LayoutParams lp = (LayoutParams) ignoreOffsetLimitView.getLayoutParams(); + ignoreOffsetLimit = ignoreOffsetLimitView.getBottom() + lp.bottomMargin; + isIgnoreOffsetLimitSet = true; + } final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); + if (child.getVisibility() == View.GONE) { + continue; + } final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (!lp.ignoreOffset) { child.offsetTopAndBottom((int) dy); + } else if (isIgnoreOffsetLimitSet) { + int top = child.getTop(); + int targetTop = Math.max( + (int) (ignoreOffsetLimit + lp.topMargin + dy), + lp.mFixedTop); + if (top != targetTop) { + child.offsetTopAndBottom(targetTop - top); + } + ignoreOffsetLimit = child.getBottom() + lp.bottomMargin; } } final boolean isCollapsedOld = mCollapseOffset != 0; @@ -1027,6 +1056,8 @@ public class ResolverDrawerLayout extends ViewGroup { final int rightEdge = width - getPaddingRight(); final int widthAvailable = rightEdge - leftEdge; + boolean isIgnoreOffsetLimitSet = false; + int ignoreOffsetLimit = 0; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); @@ -1039,9 +1070,24 @@ public class ResolverDrawerLayout extends ViewGroup { continue; } + if (mIgnoreOffsetTopLimitViewId != ID_NULL && !isIgnoreOffsetLimitSet) { + if (mIgnoreOffsetTopLimitViewId == child.getId()) { + ignoreOffsetLimit = child.getBottom() + lp.bottomMargin; + isIgnoreOffsetLimitSet = true; + } + } + int top = ypos + lp.topMargin; if (lp.ignoreOffset) { - top -= mCollapseOffset; + if (!isDragging()) { + lp.mFixedTop = (int) (top - mCollapseOffset); + } + if (isIgnoreOffsetLimitSet) { + top = Math.max(ignoreOffsetLimit + lp.topMargin, (int) (top - mCollapseOffset)); + ignoreOffsetLimit = top + child.getMeasuredHeight() + lp.bottomMargin; + } else { + top -= mCollapseOffset; + } } final int bottom = top + child.getMeasuredHeight(); @@ -1105,11 +1151,23 @@ public class ResolverDrawerLayout extends ViewGroup { mCollapsibleHeightReserved = ss.mCollapsibleHeightReserved; } + private View findIgnoreOffsetLimitView() { + if (mIgnoreOffsetTopLimitViewId == ID_NULL) { + return null; + } + View v = findViewById(mIgnoreOffsetTopLimitViewId); + if (v != null && v != this && v.getParent() == this && v.getVisibility() != View.GONE) { + return v; + } + return null; + } + public static class LayoutParams extends MarginLayoutParams { public boolean alwaysShow; public boolean ignoreOffset; public boolean hasNestedScrollIndicator; public int maxHeight; + int mFixedTop; public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); -- cgit v1.2.3-59-g8ed1b From e366d8cd25624e35bb0eeb5e1d46cbfdf12a4ae5 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 14 Nov 2022 12:11:00 -0500 Subject: Fail startup via ResolverActivity.super_onCreate() `ResolverActivity` provides this mechanism so that we can bail out of activity creation if we determine that the request is invalid. That elides a lot of the internal (~"template method") calls that the base class dispatches down to `ChooserActivity` during setup, where we've had to deal with annoying null-check/lazy-initialization requirements even when we intended to fail out. This generally should not affect our behavior for valid requests. Interestingly, there's a test changed in this CL because it appears to have been set up incorrectly, and in the original version of that test we can see that certain Chooser Intents that `ChooserActivity` attempts to reject as invalid, that nevertheless would end up getting handled by the base `ResolverActivity` during the failure flow. Because `ChooserActivity` immediately calls `finish()` as part of this flow, that behavior should only be observable for autolaunch, and otherwise the intent will be rejected as-planned; I conclude that this probably isn't intended as a mechanism to support autolaunch, and so it's OK to "regress" as long as the test is fixed. [EDIT: since this CL was originally uploaded, an intervening change by ayepin@ already fixed this same test.] The CL also cleans up one case I know we had null-checking specifically for this reason; there are probably others left to clean up later. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I28c7b1eff50e516d6bf0043aa141d13f1afce076 --- .../com/android/intentresolver/ChooserActivity.java | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ad106bba..afc3b4bd 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,17 +239,12 @@ public class ChooserActivity extends ResolverActivity implements | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - /* TODO: this is `nullable` *primarily* because we have to defer the assignment til onCreate(). - * We make the only assignment there, and *expect* it to be ready by the time we ever use it -- + /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the + * only assignment there, and expect it to be ready by the time we ever use it -- * someday if we move all the usage to a component with a narrower lifecycle (something that * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we - * should be able to make this assignment as "final." Unfortunately, for now we also have - * a vestigial design where ChooserActivity.onCreate() can invalidate a request, but it still - * has to call up to ResolverActivity.onCreate() before closing, and the base method delegates - * back down to other methods in ChooserActivity that aren't really relevant if we're closing - * (and so they'd normally want to assume it was a valid "creation," with non-null parameters). - * Any client null checks are workarounds for this condition that can be removed once that - * design is cleaned up. */ + * should be able to make this assignment as "final." + */ @Nullable private ChooserRequestParameters mChooserRequest; @@ -333,7 +328,7 @@ public class ChooserActivity extends ResolverActivity implements } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); - super.onCreate(null); + super_onCreate(null); return; } @@ -1154,10 +1149,6 @@ public class ChooserActivity extends ResolverActivity implements @Override public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - if (mChooserRequest == null) { - return; - } - if (mChooserRequest.getCallerChooserTargets().size() > 0) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, -- cgit v1.2.3-59-g8ed1b From 36202194c114d2f79c17f930f45e337e922839ba Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 14 Nov 2022 11:44:52 -0500 Subject: Pre-work to decouple ChooserGridAdapter This first CL introduces a new delegate interface and injects all the dependencies required to make `ChooserGridAdapter` a static inner class. Then the next follow-up CL can extract it to the new `grid/` directory to remove a large chunk of code from the `ChooserActivity` monolith, including a significant portion of our "UI layer." Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I86bac2d3dec615c92043bfb01399af62747cc42b --- .../android/intentresolver/ChooserActivity.java | 235 ++++++++++++++++----- .../intentresolver/grid/DirectShareViewHolder.java | 10 +- 2 files changed, 183 insertions(+), 62 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ad106bba..b23bc1b5 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1629,7 +1629,78 @@ public class ChooserActivity extends ResolverActivity implements createListController(userHandle), mChooserRequest, mMaxTargetsPerRow); - return new ChooserGridAdapter(chooserListAdapter); + + return new ChooserGridAdapter( + context, + new ChooserGridAdapter.ChooserActivityDelegate() { + @Override + public boolean shouldShowTabs() { + return ChooserActivity.this.shouldShowTabs(); + } + + @Override + public View buildContentPreview(ViewGroup parent) { + return createContentPreviewView(parent, mPreviewCoordinator); + } + + @Override + public void onTargetSelected(int itemIndex) { + startSelected(itemIndex, false, true); + } + + @Override + public void onTargetLongPressed(int selectedPosition) { + final TargetInfo longPressedTargetInfo = + mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .targetInfoForPosition( + selectedPosition, /* filtered= */ true); + // ItemViewHolder contents should always be "display resolve info" + // targets, but check just to make sure. + if (longPressedTargetInfo.isDisplayResolveInfo()) { + showTargetDetails(longPressedTargetInfo); + } + } + + @Override + public void updateProfileViewButton(View newButtonFromProfileRow) { + mProfileView = newButtonFromProfileRow; + mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); + ChooserActivity.this.updateProfileViewButton(); + } + + @Override + public int getValidTargetCount() { + return mChooserMultiProfilePagerAdapter + .getActiveListAdapter() + .getSelectableServiceTargetCount(); + } + + @Override + public void updateDirectShareExpansion(DirectShareViewHolder directShareGroup) { + RecyclerView activeAdapterView = + mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + if (mResolverDrawerLayout.isCollapsed()) { + directShareGroup.collapse(activeAdapterView); + } else { + directShareGroup.expand(activeAdapterView); + } + } + + @Override + public void handleScrollToExpandDirectShare( + DirectShareViewHolder directShareGroup, int y, int oldy) { + directShareGroup.handleScroll( + mChooserMultiProfilePagerAdapter.getActiveAdapterView(), + y, + oldy, + mMaxTargetsPerRow); + } + }, + chooserListAdapter, + shouldShowContentPreview(), + mMaxTargetsPerRow, + getNumSheetExpansions()); } @VisibleForTesting @@ -2200,15 +2271,63 @@ public class ChooserActivity extends ResolverActivity implements * handled by {@link ChooserListAdapter} */ @VisibleForTesting - public final class ChooserGridAdapter extends RecyclerView.Adapter { - private final ChooserListAdapter mChooserListAdapter; - private final LayoutInflater mLayoutInflater; - private final boolean mShowAzLabelIfPoss; + public static final class ChooserGridAdapter extends + RecyclerView.Adapter { - private DirectShareViewHolder mDirectShareViewHolder; - private int mChooserTargetWidth = 0; - - private int mFooterHeight = 0; + /** + * Injectable interface for any considerations that should be delegated to other components + * in the {@link ChooserActivity}. + * TODO: determine whether any of these methods return parameters that can safely be + * precomputed; whether any should be converted to `ChooserGridAdapter` setters to be + * invoked by external callbacks; and whether any reflect requirements that should be moved + * out of `ChooserGridAdapter` altogether. + */ + interface ChooserActivityDelegate { + /** @return whether we're showing a tabbed (multi-profile) UI. */ + boolean shouldShowTabs(); + + /** + * @return a content preview {@link View} that's appropriate for the caller's share + * content, constructed for display in the provided {@code parent} group. + */ + View buildContentPreview(ViewGroup parent); + + /** Notify the client that the item with the selected {@code itemIndex} was selected. */ + void onTargetSelected(int itemIndex); + + /** + * Notify the client that the item with the selected {@code itemIndex} was + * long-pressed. + */ + void onTargetLongPressed(int itemIndex); + + /** + * Notify the client that the provided {@code View} should be configured as the new + * "profile view" button. Callers should attach their own click listeners to implement + * behaviors on this view. + */ + void updateProfileViewButton(View newButtonFromProfileRow); + + /** + * @return the number of "valid" targets in the active list adapter. + * TODO: define "valid." + */ + int getValidTargetCount(); + + /** + * Request that the client update our {@code directShareGroup} to match their desired + * state for the "expansion" UI. + */ + void updateDirectShareExpansion(DirectShareViewHolder directShareGroup); + + /** + * Request that the client handle a scroll event that should be taken as expanding the + * provided {@code directShareGroup}. Note that this currently never happens due to a + * hard-coded condition in {@link #canExpandDirectShare()}. + */ + void handleScrollToExpandDirectShare( + DirectShareViewHolder directShareGroup, int y, int oldy); + } private static final int VIEW_TYPE_DIRECT_SHARE = 0; private static final int VIEW_TYPE_NORMAL = 1; @@ -2220,12 +2339,44 @@ public class ChooserActivity extends ResolverActivity implements private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; - ChooserGridAdapter(ChooserListAdapter wrappedAdapter) { + private final ChooserActivityDelegate mChooserActivityDelegate; + private final ChooserListAdapter mChooserListAdapter; + private final LayoutInflater mLayoutInflater; + + private final int mMaxTargetsPerRow; + private final boolean mShouldShowContentPreview; + private final int mChooserWidthPixels; + private final int mChooserRowTextOptionTranslatePixelSize; + private final boolean mShowAzLabelIfPoss; + + private DirectShareViewHolder mDirectShareViewHolder; + private int mChooserTargetWidth = 0; + + private int mFooterHeight = 0; + + ChooserGridAdapter( + Context context, + ChooserActivityDelegate chooserActivityDelegate, + ChooserListAdapter wrappedAdapter, + boolean shouldShowContentPreview, + int maxTargetsPerRow, + int numSheetExpansions) { super(); + + mChooserActivityDelegate = chooserActivityDelegate; + mChooserListAdapter = wrappedAdapter; - mLayoutInflater = LayoutInflater.from(ChooserActivity.this); + mLayoutInflater = LayoutInflater.from(context); + + mShouldShowContentPreview = shouldShowContentPreview; + mMaxTargetsPerRow = maxTargetsPerRow; + + mChooserWidthPixels = context.getResources().getDimensionPixelSize( + R.dimen.chooser_width); + mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( + R.dimen.chooser_row_text_option_translate); - mShowAzLabelIfPoss = getNumSheetExpansions() < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; + mShowAzLabelIfPoss = numSheetExpansions < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; wrappedAdapter.registerDataSetObserver(new DataSetObserver() { @Override @@ -2258,7 +2409,7 @@ public class ChooserActivity extends ResolverActivity implements } // Limit width to the maximum width of the chooser activity - int maxWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); + int maxWidth = mChooserWidthPixels; width = Math.min(maxWidth, width); int newWidth = width / mMaxTargetsPerRow; @@ -2290,11 +2441,11 @@ public class ChooserActivity extends ResolverActivity implements public int getSystemRowCount() { // For the tabbed case we show the sticky content preview above the tabs, // please refer to shouldShowStickyContentPreview - if (shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs()) { return 0; } - if (!shouldShowContentPreview()) { + if (!mShouldShowContentPreview) { return 0; } @@ -2306,7 +2457,7 @@ public class ChooserActivity extends ResolverActivity implements } public int getProfileRowCount() { - if (shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs()) { return 0; } return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; @@ -2325,8 +2476,7 @@ public class ChooserActivity extends ResolverActivity implements // There can be at most one row in the listview, that is internally // a ViewGroup with 2 rows public int getServiceTargetRowCount() { - if (shouldShowContentPreview() - && !ActivityManager.isLowRamDeviceStatic()) { + if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) { return 1; } return 0; @@ -2355,7 +2505,7 @@ public class ChooserActivity extends ResolverActivity implements switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: return new ItemViewHolder( - createContentPreviewView(parent, mPreviewCoordinator), + mChooserActivityDelegate.buildContentPreview(parent), viewType, null, null); @@ -2375,22 +2525,8 @@ public class ChooserActivity extends ResolverActivity implements return new ItemViewHolder( mChooserListAdapter.createView(parent), viewType, - selectedPosition -> startSelected( - selectedPosition, - /* always= */ false, - /* filtered= */ true), - selectedPosition -> { - final TargetInfo longPressedTargetInfo = - mChooserMultiProfilePagerAdapter - .getActiveListAdapter() - .targetInfoForPosition( - selectedPosition, /* filtered= */ true); - // ItemViewHolder contents should always be "display resolve info" - // targets, but check just to make sure. - if (longPressedTargetInfo.isDisplayResolveInfo()) { - showTargetDetails(longPressedTargetInfo); - } - }); + mChooserActivityDelegate::onTargetSelected, + mChooserActivityDelegate::onTargetLongPressed); case VIEW_TYPE_DIRECT_SHARE: case VIEW_TYPE_CALLER_AND_RANK: return createItemGroupViewHolder(viewType, parent); @@ -2450,9 +2586,7 @@ public class ChooserActivity extends ResolverActivity implements private View createProfileView(ViewGroup parent) { View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); - mProfileView = profileRow.findViewById(com.android.internal.R.id.profile_button); - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - updateProfileViewButton(); + mChooserActivityDelegate.updateProfileViewButton(profileRow); return profileRow; } @@ -2474,15 +2608,13 @@ public class ChooserActivity extends ResolverActivity implements v.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { - startSelected(holder.getItemIndex(column), false, true); + mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column)); } }); // Show menu for both direct share and app share targets after long click. v.setOnLongClickListener(v1 -> { - TargetInfo ti = mChooserListAdapter.targetInfoForPosition( - holder.getItemIndex(column), true); - showTargetDetails(ti); + mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column)); return true; }); @@ -2543,7 +2675,7 @@ public class ChooserActivity extends ResolverActivity implements mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, - mChooserMultiProfilePagerAdapter::getActiveListAdapter); + mChooserActivityDelegate::getValidTargetCount); loadViewsIntoGroup(mDirectShareViewHolder); return mDirectShareViewHolder; @@ -2609,9 +2741,7 @@ public class ChooserActivity extends ResolverActivity implements ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - float translationInPx = getResources().getDimensionPixelSize( - R.dimen.chooser_row_text_option_translate); - textView.setTranslationY(translationInPx); + textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize); ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY", 0.0f); translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); @@ -2663,9 +2793,8 @@ public class ChooserActivity extends ResolverActivity implements public void handleScroll(View v, int y, int oldy) { boolean canExpandDirectShare = canExpandDirectShare(); if (mDirectShareViewHolder != null && canExpandDirectShare) { - mDirectShareViewHolder.handleScroll( - mChooserMultiProfilePagerAdapter.getActiveAdapterView(), y, oldy, - mMaxTargetsPerRow); + mChooserActivityDelegate.handleScrollToExpandDirectShare( + mDirectShareViewHolder, y, oldy); } } @@ -2690,13 +2819,7 @@ public class ChooserActivity extends ResolverActivity implements if (mDirectShareViewHolder == null || !canExpandDirectShare()) { return; } - RecyclerView activeAdapterView = - mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - if (mResolverDrawerLayout.isCollapsed()) { - mDirectShareViewHolder.collapse(activeAdapterView); - } else { - mDirectShareViewHolder.expand(activeAdapterView); - } + mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder); } } diff --git a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java index 95c61e3a..cfd54697 100644 --- a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java +++ b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java @@ -28,7 +28,6 @@ import android.view.animation.AccelerateInterpolator; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.ChooserActivity; -import com.android.intentresolver.ChooserListAdapter; import java.util.Arrays; import java.util.List; @@ -47,14 +46,14 @@ public class DirectShareViewHolder extends ItemGroupViewHolder { private final boolean[] mCellVisibility; - private final Supplier mListAdapterSupplier; + private final Supplier mDeferredTargetCountSupplier; public DirectShareViewHolder( ViewGroup parent, List rows, int cellCountPerRow, int viewType, - Supplier listAdapterSupplier) { + Supplier deferredTargetCountSupplier) { super(rows.size() * cellCountPerRow, parent, viewType); this.mParent = parent; @@ -62,7 +61,7 @@ public class DirectShareViewHolder extends ItemGroupViewHolder { this.mCellCountPerRow = cellCountPerRow; this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; Arrays.fill(mCellVisibility, true); - this.mListAdapterSupplier = listAdapterSupplier; + this.mDeferredTargetCountSupplier = deferredTargetCountSupplier; } public ViewGroup addView(int index, View v) { @@ -136,8 +135,7 @@ public class DirectShareViewHolder extends ItemGroupViewHolder { // only expand if we have more than maxTargetsPerRow, and delay that decision // until they start to scroll - ChooserListAdapter adapter = mListAdapterSupplier.get(); - int validTargets = adapter.getSelectableServiceTargetCount(); + final int validTargets = this.mDeferredTargetCountSupplier.get(); if (validTargets <= maxTargetsPerRow) { mHideDirectShareExpansion = true; return; -- cgit v1.2.3-59-g8ed1b From 3bd1033da86ba26d922e19a21bb21dec4bf7188f Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 28 Nov 2022 12:07:14 -0500 Subject: Tighten visibility in the -ListAdapter classes. As noted in ag/19933554, client code occasionally reached directly into these components to manipulate package-private members that weren't obviously intended to be part of the API. This CL narrows the visibility of those ivars (as described below), creating new methods as necessary to expose the (now-encapsulated) data for the clients that had previously been using it directly. This CL also shows that some indirect dependencies (previously accessed via these shared ivars) can be precomputed and injected, to avoid any subsequent sharing. Finally, the CL includes some other minor readability cleanups. To narrow the visibilities, almost all ivars were marked `private`. In some cases it's convenient to grant the subclass direct access to collaborating components, so those were marked `protected` in the base class; those are exclusively `final` members (and for the most part, their APIs are even "logically stateless") so they should be much easier to reason about when reading the base class source. These classes still need substantial refactoring to define their roles and responsibilities in the system, and especially to clarify their inheritance contract; this CL is just a first step to making the existing code more understandable. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I659197b3fe22f46813dc818e90ba78546a61bec3 --- .../AbstractMultiProfilePagerAdapter.java | 4 +- .../android/intentresolver/ChooserActivity.java | 15 +++-- .../android/intentresolver/ChooserListAdapter.java | 29 +++++---- .../android/intentresolver/ResolverActivity.java | 42 +++++++----- .../intentresolver/ResolverListAdapter.java | 76 +++++++++++++++------- .../intentresolver/ChooserListAdapterTest.kt | 2 + .../intentresolver/ChooserWrapperActivity.java | 4 ++ .../intentresolver/ResolverWrapperActivity.java | 12 +++- .../intentresolver/ResolverWrapperAdapter.java | 22 +++++-- 9 files changed, 141 insertions(+), 65 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 3e1084f4..8b0b10b0 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -148,7 +148,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { @VisibleForTesting public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().mResolverListController.getUserHandle(); + return getActiveListAdapter().getUserHandle(); } @Override @@ -263,7 +263,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().mResolverListController.getUserHandle())) { + if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { return PROFILE_PERSONAL; } else { return PROFILE_WORK; diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ad106bba..5535d987 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1359,11 +1359,11 @@ public class ChooserActivity extends ResolverActivity implements targetInfo.getChooserTargetComponentName().getPackageName(); ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - int maxRankedResults = Math.min(currentListAdapter.mDisplayList.size(), - MAX_LOG_RANK_POSITION); + int maxRankedResults = Math.min( + currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); for (int i = 0; i < maxRankedResults; i++) { - if (currentListAdapter.mDisplayList.get(i) + if (currentListAdapter.getDisplayResolveInfo(i) .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { return i; } @@ -1627,6 +1627,8 @@ public class ChooserActivity extends ResolverActivity implements rList, filterLastUsed, createListController(userHandle), + userHandle, + getTargetIntent(), mChooserRequest, mMaxTargetsPerRow); return new ChooserGridAdapter(chooserListAdapter); @@ -1640,6 +1642,8 @@ public class ChooserActivity extends ResolverActivity implements List rList, boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ChooserRequestParameters chooserRequest, int maxTargetsPerRow) { return new ChooserListAdapter( @@ -1649,6 +1653,8 @@ public class ChooserActivity extends ResolverActivity implements rList, filterLastUsed, resolverListController, + userHandle, + targetIntent, this, context.getPackageManager(), getChooserActivityLogger(), @@ -1898,8 +1904,7 @@ public class ChooserActivity extends ResolverActivity implements .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); } - if (chooserListAdapter.mDisplayList == null - || chooserListAdapter.mDisplayList.isEmpty()) { + if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { chooserListAdapter.notifyDataSetChanged(); } else { chooserListAdapter.updateAlphabeticalList(); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index b18d2718..59d1a6e3 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -37,6 +37,7 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.AsyncTask; import android.os.Trace; +import android.os.UserHandle; import android.os.UserManager; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; @@ -67,8 +68,6 @@ public class ChooserListAdapter extends ResolverListAdapter { private static final String TAG = "ChooserListAdapter"; private static final boolean DEBUG = false; - private boolean mEnableStackedApps = true; - public static final int NO_POSITION = -1; public static final int TARGET_BAD = -1; public static final int TARGET_CALLER = 0; @@ -95,11 +94,11 @@ public class ChooserListAdapter extends ResolverListAdapter { private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); + private final ShortcutSelectionLogic mShortcutSelectionLogic; + // Sorted list of DisplayResolveInfos for the alphabetical app section. private List mSortedList = new ArrayList<>(); - private final ShortcutSelectionLogic mShortcutSelectionLogic; - // For pinned direct share labels, if the text spans multiple lines, the TextView will consume // the full width, even if the characters actually take up less than that. Measure the actual // line widths and constrain the View's width based upon that so that the pin doesn't end up @@ -138,6 +137,8 @@ public class ChooserListAdapter extends ResolverListAdapter { List rList, boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, ChooserActivityLogger chooserActivityLogger, @@ -145,8 +146,17 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. - super(context, payloadIntents, null, rList, filterLastUsed, - resolverListController, resolverListCommunicator, false); + super( + context, + payloadIntents, + null, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + false); mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; @@ -334,11 +344,8 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override protected List doInBackground(Void... voids) { List allTargets = new ArrayList<>(); - allTargets.addAll(mDisplayList); + allTargets.addAll(getTargetsInCurrentDisplayList()); allTargets.addAll(mCallerTargets); - if (!mEnableStackedApps) { - return allTargets; - } // Consolidate multiple targets from same app. return allTargets @@ -408,7 +415,7 @@ public class ChooserListAdapter extends ResolverListAdapter { int getAlphaTargetCount() { int groupedCount = mSortedList.size(); - int ungroupedCount = mCallerTargets.size() + mDisplayList.size(); + int ungroupedCount = mCallerTargets.size() + getDisplayResolveInfoCount(); return (ungroupedCount > mMaxRankedTargets) ? groupedCount : 0; } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index fece8d3d..5a116b43 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -851,7 +851,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @Override // SelectableTargetInfoCommunicator ResolverListCommunicator public Intent getTargetIntent() { return mIntents.isEmpty() ? null : mIntents.get(0); } @@ -1532,8 +1531,16 @@ public class ResolverActivity extends FragmentActivity implements Intent startIntent = getIntent(); boolean isAudioCaptureDevice = startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new ResolverListAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, createListController(userHandle), this, + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, isAudioCaptureDevice); } @@ -1597,12 +1604,13 @@ public class ResolverActivity extends FragmentActivity implements setContentView(mLayoutId); DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList.get(0); + mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; final ResolverListAdapter inactiveAdapter = mMultiProfilePagerAdapter.getInactiveListAdapter(); - final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0); + final DisplayResolveInfo otherProfileResolveInfo = + inactiveAdapter.getFirstDisplayResolveInfo(); // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); @@ -1653,31 +1661,29 @@ public class ResolverActivity extends FragmentActivity implements || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { return false; } - List sameProfileList = - mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList; - List otherProfileList = - mMultiProfilePagerAdapter.getInactiveListAdapter().mDisplayList; + ResolverListAdapter sameProfileAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + ResolverListAdapter otherProfileAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (sameProfileList.isEmpty()) { + if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); return false; } - if (otherProfileList.size() != 1) { - Log.d(TAG, "Found " + otherProfileList.size() + " resolvers in the other profile"); + if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { + Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); return false; } - if (otherProfileList.get(0).getResolveInfo().handleAllWebDataURI) { + if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { Log.d(TAG, "Other profile is a web browser"); return false; } - for (DisplayResolveInfo info : sameProfileList) { - if (!info.getResolveInfo().handleAllWebDataURI) { - Log.d(TAG, "Non-browser found in this profile"); - return false; - } + if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { + Log.d(TAG, "Non-browser found in this profile"); + return false; } return true; diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index d97191c6..9f654594 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -56,6 +56,8 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; + import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -65,37 +67,48 @@ import java.util.Map; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; + @Nullable // TODO: other model for lazy computation? Or just precompute? + private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; + + protected final Context mContext; + protected final LayoutInflater mInflater; + protected final ResolverListCommunicator mResolverListCommunicator; + protected final ResolverListController mResolverListController; + private final List mIntents; private final Intent[] mInitialIntents; private final List mBaseResolveList; private final PackageManager mPm; - protected final Context mContext; - private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; private final int mIconDpi; - protected ResolveInfo mLastChosen; + private final boolean mIsAudioCaptureDevice; + private final UserHandle mUserHandle; + private final Intent mTargetIntent; + + private final Map mIconLoaders = new HashMap<>(); + private final Map mLabelLoaders = new HashMap<>(); + + private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; - ResolverListController mResolverListController; private int mPlaceholderCount; - protected final LayoutInflater mInflater; - // This one is the list that the Adapter will actually present. - List mDisplayList; + private List mDisplayList; private List mUnfilteredResolveList; private int mLastChosenPosition = -1; private boolean mFilterLastUsed; - final ResolverListCommunicator mResolverListCommunicator; private Runnable mPostListReadyRunnable; - private final boolean mIsAudioCaptureDevice; private boolean mIsTabLoaded; - private final Map mIconLoaders = new HashMap<>(); - private final Map mLabelLoaders = new HashMap<>(); - public ResolverListAdapter(Context context, List payloadIntents, - Intent[] initialIntents, List rList, + public ResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator, boolean isAudioCaptureDevice) { mContext = context; @@ -107,12 +120,22 @@ public class ResolverListAdapter extends BaseAdapter { mDisplayList = new ArrayList<>(); mFilterLastUsed = filterLastUsed; mResolverListController = resolverListController; + mUserHandle = userHandle; + mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mIsAudioCaptureDevice = isAudioCaptureDevice; final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); mIconDpi = am.getLauncherLargeIconDensity(); } + public final DisplayResolveInfo getFirstDisplayResolveInfo() { + return mDisplayList.get(0); + } + + public final ImmutableList getTargetsInCurrentDisplayList() { + return ImmutableList.copyOf(mDisplayList); + } + public void handlePackagesChanged() { mResolverListCommunicator.onHandlePackagesChanged(this); } @@ -262,7 +285,7 @@ public class ResolverListAdapter extends BaseAdapter { if (mBaseResolveList != null) { List currentResolveList = new ArrayList<>(); mResolverListController.addResolveListDedupe(currentResolveList, - mResolverListCommunicator.getTargetIntent(), + mTargetIntent, mBaseResolveList); return currentResolveList; } else { @@ -338,7 +361,12 @@ public class ResolverListAdapter extends BaseAdapter { if (otherProfileInfo != null) { mOtherProfile = makeOtherProfileDisplayResolveInfo( - mContext, otherProfileInfo, mPm, mResolverListCommunicator, mIconDpi); + mContext, + otherProfileInfo, + mPm, + mTargetIntent, + mResolverListCommunicator, + mIconDpi); } else { mOtherProfile = null; try { @@ -499,7 +527,7 @@ public class ResolverListAdapter extends BaseAdapter { final Intent replaceIntent = mResolverListCommunicator.getReplacementIntent(add.activityInfo, intent); final Intent defaultIntent = mResolverListCommunicator.getReplacementIntent( - add.activityInfo, mResolverListCommunicator.getTargetIntent()); + add.activityInfo, mTargetIntent); final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( intent, add, @@ -608,11 +636,15 @@ public class ResolverListAdapter extends BaseAdapter { return position; } - public int getDisplayResolveInfoCount() { + public final int getDisplayResolveInfoCount() { return mDisplayList.size(); } - public DisplayResolveInfo getDisplayResolveInfo(int index) { + public final boolean allResolveInfosHandleAllWebDataUri() { + return mDisplayList.stream().allMatch(t -> t.getResolveInfo().handleAllWebDataURI); + } + + public final DisplayResolveInfo getDisplayResolveInfo(int index) { // Used to query services. We only query services for primary targets, not alternates. return mDisplayList.get(index); } @@ -765,7 +797,7 @@ public class ResolverListAdapter extends BaseAdapter { } public UserHandle getUserHandle() { - return mResolverListController.getUserHandle(); + return mUserHandle; } protected List getResolversForUser(UserHandle userHandle) { @@ -821,6 +853,7 @@ public class ResolverListAdapter extends BaseAdapter { Context context, ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator, int iconDpi) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); @@ -829,8 +862,7 @@ public class ResolverListAdapter extends BaseAdapter { resolveInfo.activityInfo, resolvedComponentInfo.getIntentAt(0)); Intent replacementIntent = resolverListCommunicator.getReplacementIntent( - resolveInfo.activityInfo, - resolverListCommunicator.getTargetIntent()); + resolveInfo.activityInfo, targetIntent); ResolveInfoPresentationGetter presentationGetter = new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo); @@ -871,8 +903,6 @@ public class ResolverListAdapter extends BaseAdapter { */ default boolean shouldGetOnlyDefaultActivities() { return true; }; - Intent getTargetIntent(); - void onHandlePackagesChanged(ResolverListAdapter listAdapter); } diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index d054e7fa..6b34f8b9 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -56,6 +56,8 @@ class ChooserListAdapterTest { emptyList(), false, resolverListController, + null, + Intent(), mock(), packageManager, chooserActivityLogger, diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index fe448d63..8c842786 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -73,6 +73,8 @@ public class ChooserWrapperActivity List rList, boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ChooserRequestParameters chooserRequest, int maxTargetsPerRow) { PackageManager packageManager = @@ -85,6 +87,8 @@ public class ChooserWrapperActivity rList, filterLastUsed, resolverListController, + userHandle, + targetIntent, this, packageManager, getChooserActivityLogger(), diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 7d4b07d8..239bffe0 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -59,8 +59,16 @@ public class ResolverWrapperActivity extends ResolverActivity { public ResolverListAdapter createResolverListAdapter(Context context, List payloadIntents, Intent[] initialIntents, List rList, boolean filterLastUsed, UserHandle userHandle) { - return new ResolverWrapperAdapter(context, payloadIntents, initialIntents, rList, - filterLastUsed, createListController(userHandle), this); + return new ResolverWrapperAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + payloadIntents.get(0), // TODO: extract upstream + this); } @Override diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java index 1504a8ab..a53b41d1 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java @@ -19,6 +19,7 @@ package com.android.intentresolver; import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; +import android.os.UserHandle; import androidx.test.espresso.idling.CountingIdlingResource; @@ -31,14 +32,27 @@ public class ResolverWrapperAdapter extends ResolverListAdapter { private CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - public ResolverWrapperAdapter(Context context, + public ResolverWrapperAdapter( + Context context, List payloadIntents, Intent[] initialIntents, - List rList, boolean filterLastUsed, + List rList, + boolean filterLastUsed, ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, ResolverListCommunicator resolverListCommunicator) { - super(context, payloadIntents, initialIntents, rList, filterLastUsed, - resolverListController, resolverListCommunicator, false); + super( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + false); } public CountingIdlingResource getLabelIdlingResource() { -- cgit v1.2.3-59-g8ed1b From 9864ca7246e07d90c07717ec82c686d4f7faa02e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 1 Dec 2022 11:07:33 -0500 Subject: Fix NPE from dependency initialization order This regression was caused by a botched rebase in ag/20455545 that caused the preview coordinator to be initialized immediately *after* the call up to `super.onCreate()`; it's a dependency for that method and needs to be initialized before (the lines got swapped in the attempted merge). I believe the regression was missed by our tests because of a race where the coordinator effectively gets initialized before any preview-loading tasks get dequeued and start relying on it. It's hard to verify a fix for that kind of flakiness, but fortunately it's obvious how this regression was introduced and why this fixes it. Post-mortem notes: 1. This is a "billion dollar mistake" -- a lot of our dependencies are nullable in the activities because we're unable to initialize them prior to onCreate(). Some options: a. Move these dependencies (and *all* logic that depends on them) into some controller class to decouple from the activity lifecycle. We can instantiate the one controller in onCreate(), but then internally all dependencies are final/non-null. (This would be a big improvement in our design, but the required changes are pretty heavyweight.) b. Formalize our helper for lazy computation so that dependencies never come up "null" simply because we've forgotten to initialize them. This is a more complex design than one where we just initialize everything we need, and it's not necessarily the correct model for all our problems, but it's easier to get right than the current late-init/nullability model. c. Integrate Dagger and generally enjoy an easier time managing dependencies (including a nice formal model for lazy init, if we want). This is probably something we want anyways so that we can migrate away from our inheritance-based test infrastructure, and I expect we'll keep finding more reasons to want it. 2. This would've been less likely to happen if the coordinator was an explicit dependency of `ResolverActivity.onCreate()`; instead, `ChooserActivity.onCreate()` makes a super call partway through that encapsulates an unknown number of additional responsibilities, and before it returns, it makes several calls back down to `ChooserActivity` for other dependencies -- before the activity is finished "creating," and without any explicit inheritance contract to say what's expected of implementors. Activity inheritance causes us any number of other problems, and we're not going to be able to rely on it in the future (e.g. for my embedded share prototype, ag/20519681, which can't be implemented by an Activity). We need to start paying down tech debt around this contract, figuring out where the two activity implementations overlap/differ, and ultimately breaking their inheritance relationship. It might be a good start for us to add explicit parameters to `ResolverActivity.onCreate()` for any of the dependencies it currently delegates back down for. Bug: 260934576 Test: `atest UnbundledChooserActivityTest` -- and tested that this same command failed (flaky) w/o the fix. As noted above, it's hard to verify this as a fix (vs. flake), but the correctness of the change should be obvious. Change-Id: Ie57b551d7d2d013049e86daaba18c5ff4f4c569f --- java/src/com/android/intentresolver/ChooserActivity.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ad106bba..1c52d59f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -147,7 +147,6 @@ import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; -import java.util.function.Supplier; /** * The Chooser Activity handles intent resolution specifically for sharing intents - @@ -355,6 +354,12 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); + mPreviewCoordinator = new ChooserContentPreviewCoordinator( + mBackgroundThreadPoolExecutor, + this, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); + super.onCreate( savedInstanceState, mChooserRequest.getTargetIntent(), @@ -364,12 +369,6 @@ public class ChooserActivity extends ResolverActivity implements /* rList: List = */ null, /* supportsAlwaysUseOption = */ false); - mPreviewCoordinator = new ChooserContentPreviewCoordinator( - mBackgroundThreadPoolExecutor, - this, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; -- cgit v1.2.3-59-g8ed1b From d273b387e953ba02f79d48e92ef8e8c8e064703b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 2 Dec 2022 10:54:27 -0800 Subject: Fix ChooserActivity crash when launched with a caller-provided target Fix java.lang.UnsupportedOperationException at ShortcutSelectionLogic.addServiceResults() when trying to sort an unmodifiable collection. A new unit test for launcing ChooserActivity with a caller-provided target is added. Fix: 261215405 Test: manual test Test: atest IntentResolverUnitTests Change-Id: I8a28e074044e932753ab4906409cded67f4ccdad --- .../android/intentresolver/ChooserActivity.java | 2 +- .../UnbundledChooserActivityTest.java | 85 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index fe1df879..ebf0d203 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1160,7 +1160,7 @@ public class ChooserActivity extends ResolverActivity implements if (mChooserRequest.getCallerChooserTargets().size() > 0) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - mChooserRequest.getCallerChooserTargets(), + new ArrayList<>(mChooserRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index da72a749..28b68530 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -78,6 +78,7 @@ import android.graphics.Paint; import android.graphics.drawable.Icon; import android.metrics.LogMaker; import android.net.Uri; +import android.os.Bundle; import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; @@ -1635,6 +1636,90 @@ public class UnbundledChooserActivityTest { is("testTitle1")); } + @Test + public void testLaunchWithCallerProvidedTarget() { + setDeviceConfigProperty( + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false)); + // Set up resources + ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + when( + ChooserActivityOverrideData + .getInstance() + .resources + .getInteger(R.integer.config_maxShortcutTargetsPerApp)) + .thenReturn(1); + + // We need app targets for direct targets to get displayed + List resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + // set caller-provided target + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String callerTargetLabel = "Caller Target"; + ChooserTarget[] targets = new ChooserTarget[] { + new ChooserTarget( + callerTargetLabel, + Icon.createWithBitmap(createBitmap()), + 0.1f, + resolvedComponentInfos.get(0).name, + new Bundle()) + }; + chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray>> shortcutLoaders = + createShortcutLoaderFactory(); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + + // send shortcuts + assertThat( + "Wrong number of app targets", + appTargets.getValue().length, + is(resolvedComponentInfos.size())); + ShortcutLoader.Result result = new ShortcutLoader.Result( + true, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[0], + new HashMap<>(), + new HashMap<>()); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + final ChooserListAdapter activeAdapter = activity.getAdapter(); + assertThat( + "Chooser should have 3 targets (2 apps, 1 direct)", + activeAdapter.getCount(), + is(3)); + assertThat( + "Chooser should have exactly two selectable direct target", + activeAdapter.getSelectableServiceTargetCount(), + is(1)); + assertThat( + "The display label must match", + activeAdapter.getItem(0).getDisplayLabel(), + is(callerTargetLabel)); + } + @Test public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); -- cgit v1.2.3-59-g8ed1b From 44c34326ef042d849a23939d3198bc7fe354cea3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 5 Dec 2022 18:32:05 -0800 Subject: Load app icons defensively Use a placeholder when an app icon or a direct share icon loading failed with an exception. Bug: 260924828 Test: functionality smoke test Test: manual test with simulate exceptions Test: atest IntentResolverUnitTests Change-Id: Id3e4311c6bcc23a78043622b018c410985c8b701 --- .../android/intentresolver/ChooserListAdapter.java | 23 ++++++++++++------ .../intentresolver/ResolverListAdapter.java | 27 ++++++++++++++++++---- 2 files changed, 38 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 59d1a6e3..b9a78402 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -259,8 +259,7 @@ public class ChooserListAdapter extends ResolverListAdapter { final ViewHolder holder = (ViewHolder) view.getTag(); if (info == null) { - holder.icon.setImageDrawable( - mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + holder.icon.setImageDrawable(loadIconPlaceholder()); return; } @@ -657,11 +656,21 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override protected Drawable doInBackground(Void... voids) { - return getChooserTargetIconDrawable( - mContext, - mTargetInfo.getChooserTargetIcon(), - mTargetInfo.getChooserTargetComponentName(), - mTargetInfo.getDirectShareShortcutInfo()); + Drawable drawable; + try { + drawable = getChooserTargetIconDrawable( + mContext, + mTargetInfo.getChooserTargetIcon(), + mTargetInfo.getChooserTargetComponentName(), + mTargetInfo.getDirectShareShortcutInfo()); + } catch (Exception e) { + Log.e(TAG, + "Failed to load shortcut icon for " + + mTargetInfo.getChooserTargetComponentName(), + e); + drawable = loadIconPlaceholder(); + } + return drawable; } @Override diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 9f654594..28bbfc50 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -677,8 +677,7 @@ public class ResolverListAdapter extends BaseAdapter { protected void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); if (info == null) { - holder.icon.setImageDrawable( - mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + holder.icon.setImageDrawable(loadIconPlaceholder()); holder.bindLabel("", "", false); return; } @@ -704,7 +703,7 @@ public class ResolverListAdapter extends BaseAdapter { protected final void loadIcon(DisplayResolveInfo info) { LoadIconTask task = mIconLoaders.get(info); if (task == null) { - task = new LoadIconTask((DisplayResolveInfo) info); + task = new LoadIconTask(info); mIconLoaders.put(info, task); task.execute(); } @@ -779,13 +778,25 @@ public class ResolverListAdapter extends BaseAdapter { return makePresentationGetter(ri).getIcon(getUserHandle()); } + protected final Drawable loadIconPlaceholder() { + return mContext.getDrawable(R.drawable.resolver_icon_placeholder); + } + void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconView != null && iconInfo != null) { new AsyncTask() { @Override protected Drawable doInBackground(Void... params) { - return loadIconForResolveInfo(iconInfo.getResolveInfo()); + Drawable drawable; + try { + drawable = loadIconForResolveInfo(iconInfo.getResolveInfo()); + } catch (Exception e) { + ComponentName componentName = iconInfo.getResolvedComponentName(); + Log.e(TAG, "Failed to load app icon for " + componentName, e); + drawable = loadIconPlaceholder(); + } + return drawable; } @Override @@ -1021,7 +1032,13 @@ public class ResolverListAdapter extends BaseAdapter { @Override protected Drawable doInBackground(Void... params) { - return loadIconForResolveInfo(mResolveInfo); + try { + return loadIconForResolveInfo(mResolveInfo); + } catch (Exception e) { + ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName(); + Log.e(TAG, "Failed to load app icon for " + componentName, e); + return loadIconPlaceholder(); + } } @Override -- cgit v1.2.3-59-g8ed1b From 85257d68d79fe1d1188b7d4365f35f7d72801355 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 15 Nov 2022 10:46:47 -0500 Subject: SelectableTargetInfo "immutability"/other cleanup This CL makes many small "pure refactoring" changes as part of my ongoing go/chooser-targetinfo-cleanup. 1. Most important: introduce APIs to manage "functional"/"mutable" aspects of `SelectableTargetInfo`: `IconHolder` (to manage icon getting/setting), `TargetActivityStarter` (for the various `start` methods in `TargetInfo`), and `TargetHashProvider` (to compute a hash we use for metrics logging). I pulled `IconHolder` up into the base API and exposed it directly to clients (since they're really the ones that manipulate the "set" icon, and eventually they shold take over more of the icon-management responsibilities, as I've noted in a new TODO comment). I've left the others in `SelectableTargetInfo` for now since their responsibilities aren't as concerning in an "immutable" `TargetInfo` -- they defer some computation that's parameterized on the caller arguments, but they don't directly mutate any state in the target object. Eventually, the other interfaces can move to the upcoming new `ImmutableTargetInfo` type (where it will be convenient having the ability to copy these sub-components as whole objects). 2. Precompute any other fields in `SelectableTargetInfo` to show that we're now basically ready to implement immutability. 3. Extract `ChooserTarget` fields in the `SelectableTargetInfo` "factory method" -- that deprecated type is no longer part of the API of this `TargetInfo` implementation. Also add a new factory overload to skip the `ChooserTarget` representation for synthetic targets where it was only used as an intermediary in shuttling around these particular fields. 4. Implement `SelectableTargetInfo` "copy constructor" in terms of its primary (internal) constructor to make sure we perform any necessary initialization steps consistently. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: If6f0cceebc4627fcd9a796948738c34195269e6d --- .../android/intentresolver/ChooserActivity.java | 9 +- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../android/intentresolver/ResolverActivity.java | 2 +- .../intentresolver/ResolverListAdapter.java | 4 +- .../intentresolver/chooser/DisplayResolveInfo.java | 18 +- .../chooser/NotSelectableTargetInfo.java | 37 ++- .../chooser/SelectableTargetInfo.java | 331 ++++++++++++++------- .../android/intentresolver/chooser/TargetInfo.java | 49 ++- .../intentresolver/ChooserListAdapterTest.kt | 10 +- .../intentresolver/ShortcutSelectionLogicTest.kt | 27 +- .../intentresolver/chooser/TargetInfoTest.kt | 4 +- 11 files changed, 336 insertions(+), 157 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ad106bba..f6feaf77 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -902,7 +902,8 @@ public class ChooserActivity extends ResolverActivity implements "", resolveIntent, null); - dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + dri.getDisplayIconHolder().setDisplayIcon( + getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; } @@ -946,7 +947,7 @@ public class ChooserActivity extends ResolverActivity implements final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( originalIntent, ri, name, "", resolveIntent, null); - dri.setDisplayIcon(icon); + dri.getDisplayIconHolder().setDisplayIcon(icon); return dri; } @@ -976,7 +977,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(), + ti.getDisplayIconHolder().getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via nearby @@ -999,7 +1000,7 @@ public class ChooserActivity extends ResolverActivity implements if (ti == null) return null; final Button b = createActionButton( - ti.getDisplayIcon(), + ti.getDisplayIconHolder().getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { // Log share completion via edit diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index b18d2718..c3926889 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -660,7 +660,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override protected void onPostExecute(@Nullable Drawable icon) { if (icon != null && !mTargetInfo.hasDisplayIcon()) { - mTargetInfo.setDisplayIcon(icon); + mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); notifyDataSetChanged(); } } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index fece8d3d..64620a9d 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -1610,7 +1610,7 @@ public class ResolverActivity extends FragmentActivity implements @Override protected void onPostExecute(Drawable drawable) { if (!isDestroyed()) { - otherProfileResolveInfo.setDisplayIcon(drawable); + otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); } } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index d97191c6..89bee17a 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -920,7 +920,7 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIcon()); + icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon()); if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { @@ -999,7 +999,7 @@ public class ResolverListAdapter extends BaseAdapter { if (getOtherProfile() == mDisplayResolveInfo) { mResolverListCommunicator.updateProfileViewButton(); } else if (!mDisplayResolveInfo.hasDisplayIcon()) { - mDisplayResolveInfo.setDisplayIcon(d); + mDisplayResolveInfo.getDisplayIconHolder().setDisplayIcon(d); notifyDataSetChanged(); } } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 16dd28bc..c1b007af 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -24,7 +24,6 @@ import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; @@ -42,13 +41,13 @@ import java.util.List; public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; private CharSequence mDisplayLabel; - private Drawable mDisplayIcon; private CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List mSourceIntents = new ArrayList<>(); private final boolean mIsSuspended; private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; private boolean mPinned = false; + private final IconHolder mDisplayIconHolder = new SettableIconHolder(); /** Create a new {@code DisplayResolveInfo} instance. */ public static DisplayResolveInfo newDisplayResolveInfo( @@ -103,7 +102,6 @@ public class DisplayResolveInfo implements TargetInfo { | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); mResolvedIntent = intent; - } private DisplayResolveInfo( @@ -115,11 +113,12 @@ public class DisplayResolveInfo implements TargetInfo { mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; - mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = new Intent(other.mResolvedIntent); mResolvedIntent.fillIn(fillInIntent, flags); mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + + mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } protected DisplayResolveInfo(DisplayResolveInfo other) { @@ -127,10 +126,11 @@ public class DisplayResolveInfo implements TargetInfo { mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; - mDisplayIcon = other.mDisplayIcon; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; + + mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @Override @@ -163,8 +163,8 @@ public class DisplayResolveInfo implements TargetInfo { } @Override - public Drawable getDisplayIcon() { - return mDisplayIcon; + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; } @Override @@ -186,10 +186,6 @@ public class DisplayResolveInfo implements TargetInfo { mSourceIntents.add(alt); } - public void setDisplayIcon(Drawable icon) { - mDisplayIcon = icon; - } - public CharSequence getExtendedInfo() { return mExtendedInfo; } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 3b4b89b1..d6333374 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -43,11 +43,6 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { public boolean isEmptyTargetInfo() { return true; } - - @Override - public Drawable getDisplayIcon() { - return null; - } }; } @@ -63,11 +58,20 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { } @Override - public Drawable getDisplayIcon() { - AnimatedVectorDrawable avd = (AnimatedVectorDrawable) - context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); - avd.start(); // Start animation after generation. - return avd; + public IconHolder getDisplayIconHolder() { + return new IconHolder() { + @Override + public Drawable getDisplayIcon() { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) + context.getDrawable( + R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation. + return avd; + } + + @Override + public void setDisplayIcon(Drawable icon) {} + }; } @Override @@ -132,4 +136,17 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { public boolean isPinned() { return false; } + + @Override + public IconHolder getDisplayIconHolder() { + return new IconHolder() { + @Override + public Drawable getDisplayIcon() { + return null; + } + + @Override + public void setDisplayIcon(Drawable icon) {} + }; + } } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 51a776db..3ab50175 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -24,7 +24,6 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.UserHandle; @@ -47,6 +46,16 @@ import java.util.List; public final class SelectableTargetInfo extends ChooserTargetInfo { private static final String TAG = "SelectableTargetInfo"; + private interface TargetHashProvider { + HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context); + } + + private interface TargetActivityStarter { + boolean start(Activity activity, Bundle options); + boolean startAsCaller(Activity activity, Bundle options, int userId); + boolean startAsUser(Activity activity, Bundle options, UserHandle user); + } + private static final String HASHED_STRING_CACHE_TAG = "ChooserActivity"; // For legacy reasons. private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; @@ -67,9 +76,20 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final ShortcutInfo mShortcutInfo; private final ComponentName mChooserTargetComponentName; - private final String mChooserTargetUnsanitizedTitle; + private final CharSequence mChooserTargetUnsanitizedTitle; private final Icon mChooserTargetIcon; private final Bundle mChooserTargetIntentExtras; + private final int mFillInFlags; + private final boolean mIsPinned; + private final float mModifiedScore; + private final boolean mIsSuspended; + private final ComponentName mResolvedComponentName; + private final Intent mBaseIntentToSend; + private final ResolveInfo mResolveInfo; + private final List mAllSourceIntents; + private final IconHolder mDisplayIconHolder = new SettableIconHolder(); + private final TargetHashProvider mHashProvider; + private final TargetActivityStarter mActivityStarter; /** * A refinement intent from the caller, if any (see @@ -82,13 +102,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { * in its extended data under the key {@link Intent#EXTRA_REFERRER}. */ private final Intent mReferrerFillInIntent; - private final int mFillInFlags; - private final boolean mIsPinned; - private final float mModifiedScore; - private Drawable mDisplayIcon; - - /** Create a new {@link TargetInfo} instance representing a selectable target. */ + /** + * Create a new {@link TargetInfo} instance representing a selectable target. Some target + * parameters are copied over from the (deprecated) legacy {@link ChooserTarget} structure. + * + * @deprecated Use the overload that doesn't call for a {@link ChooserTarget}. + */ + @Deprecated public static TargetInfo newSelectableTargetInfo( @Nullable DisplayResolveInfo sourceInfo, @Nullable ResolveInfo backupResolveInfo, @@ -98,65 +119,175 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Nullable ShortcutInfo shortcutInfo, @Nullable AppTarget appTarget, Intent referrerFillInIntent) { - return new SelectableTargetInfo( + return newSelectableTargetInfo( sourceInfo, backupResolveInfo, resolvedIntent, - chooserTarget, + chooserTarget.getComponentName(), + chooserTarget.getTitle(), + chooserTarget.getIcon(), + chooserTarget.getIntentExtras(), modifiedScore, shortcutInfo, appTarget, referrerFillInIntent); } - private SelectableTargetInfo( + /** + * Create a new {@link TargetInfo} instance representing a selectable target. `chooserTarget*` + * parameters were historically retrieved from (now-deprecated) {@link ChooserTarget} structures + * even when the {@link TargetInfo} was a system (internal) synthesized target that never needed + * to be represented as a {@link ChooserTarget}. The values passed here are copied in directly + * as if they had been provided in the legacy representation. + * + * TODO: clarify semantics of how clients use the `getChooserTarget*()` methods; refactor/rename + * to avoid making reference to the legacy type; and reflect the improved semantics in the + * signature (and documentation) of this method. + */ + public static TargetInfo newSelectableTargetInfo( @Nullable DisplayResolveInfo sourceInfo, @Nullable ResolveInfo backupResolveInfo, Intent resolvedIntent, - ChooserTarget chooserTarget, + ComponentName chooserTargetComponentName, + CharSequence chooserTargetUnsanitizedTitle, + Icon chooserTargetIcon, + @Nullable Bundle chooserTargetIntentExtras, float modifiedScore, @Nullable ShortcutInfo shortcutInfo, @Nullable AppTarget appTarget, Intent referrerFillInIntent) { + return new SelectableTargetInfo( + sourceInfo, + backupResolveInfo, + resolvedIntent, + chooserTargetComponentName, + chooserTargetUnsanitizedTitle, + chooserTargetIcon, + chooserTargetIntentExtras, + modifiedScore, + shortcutInfo, + appTarget, + referrerFillInIntent, + /* fillInIntent = */ null, + /* fillInFlags = */ 0); + } + + private SelectableTargetInfo( + @Nullable DisplayResolveInfo sourceInfo, + @Nullable ResolveInfo backupResolveInfo, + Intent resolvedIntent, + ComponentName chooserTargetComponentName, + CharSequence chooserTargetUnsanitizedTitle, + Icon chooserTargetIcon, + Bundle chooserTargetIntentExtras, + float modifiedScore, + @Nullable ShortcutInfo shortcutInfo, + @Nullable AppTarget appTarget, + Intent referrerFillInIntent, + @Nullable Intent fillInIntent, + int fillInFlags) { mSourceInfo = sourceInfo; + mBackupResolveInfo = backupResolveInfo; + mResolvedIntent = resolvedIntent; mModifiedScore = modifiedScore; mShortcutInfo = shortcutInfo; mAppTarget = appTarget; - mIsPinned = shortcutInfo != null && shortcutInfo.isPinned(); - mBackupResolveInfo = backupResolveInfo; - mResolvedIntent = resolvedIntent; mReferrerFillInIntent = referrerFillInIntent; + mFillInIntent = fillInIntent; + mFillInFlags = fillInFlags; + mChooserTargetComponentName = chooserTargetComponentName; + mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle; + mChooserTargetIcon = chooserTargetIcon; + mChooserTargetIntentExtras = chooserTargetIntentExtras; - mFillInIntent = null; - mFillInFlags = 0; + mIsPinned = (shortcutInfo != null) && shortcutInfo.isPinned(); + mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); + mIsSuspended = (mSourceInfo != null) && mSourceInfo.isSuspended(); + mResolveInfo = (mSourceInfo != null) ? mSourceInfo.getResolveInfo() : mBackupResolveInfo; + + mResolvedComponentName = getResolvedComponentName(mSourceInfo, mBackupResolveInfo); + + mAllSourceIntents = getAllSourceIntents(sourceInfo); + + mBaseIntentToSend = getBaseIntentToSend( + mResolvedIntent, + mFillInIntent, + mFillInFlags, + mReferrerFillInIntent); + + mHashProvider = context -> { + final String plaintext = + getChooserTargetComponentName().getPackageName() + + mChooserTargetUnsanitizedTitle; + return HashedStringCache.getInstance().hashString( + context, + HASHED_STRING_CACHE_TAG, + plaintext, + mMaxHashSaltDays); + }; + + mActivityStarter = new TargetActivityStarter() { + @Override + public boolean start(Activity activity, Bundle options) { + throw new RuntimeException("ChooserTargets should be started as caller."); + } - mChooserTargetComponentName = chooserTarget.getComponentName(); - mChooserTargetUnsanitizedTitle = chooserTarget.getTitle().toString(); - mChooserTargetIcon = chooserTarget.getIcon(); - mChooserTargetIntentExtras = chooserTarget.getIntentExtras(); + @Override + public boolean startAsCaller(Activity activity, Bundle options, int userId) { + final Intent intent = mBaseIntentToSend; + if (intent == null) { + return false; + } + intent.setComponent(getChooserTargetComponentName()); + intent.putExtras(mChooserTargetIntentExtras); + TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); + + // Important: we will ignore the target security checks in ActivityManager if and + // only if the ChooserTarget's target package is the same package where we got the + // ChooserTargetService that provided it. This lets a ChooserTargetService provide + // a non-exported or permission-guarded target for the user to pick. + // + // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere + // so we'll obey the caller's normal security checks. + final boolean ignoreTargetSecurity = (mSourceInfo != null) + && mSourceInfo.getResolvedComponentName().getPackageName() + .equals(getChooserTargetComponentName().getPackageName()); + activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); + return true; + } - mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + throw new RuntimeException("ChooserTargets should be started as caller."); + } + }; } private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { - mSourceInfo = other.mSourceInfo; - mBackupResolveInfo = other.mBackupResolveInfo; - mResolvedIntent = other.mResolvedIntent; - mShortcutInfo = other.mShortcutInfo; - mAppTarget = other.mAppTarget; - mDisplayIcon = other.mDisplayIcon; - mFillInIntent = fillInIntent; - mFillInFlags = flags; - mModifiedScore = other.mModifiedScore; - mIsPinned = other.mIsPinned; - mReferrerFillInIntent = other.mReferrerFillInIntent; + this( + other.mSourceInfo, + other.mBackupResolveInfo, + other.mResolvedIntent, + other.mChooserTargetComponentName, + other.mChooserTargetUnsanitizedTitle, + other.mChooserTargetIcon, + other.mChooserTargetIntentExtras, + other.mModifiedScore, + other.mShortcutInfo, + other.mAppTarget, + other.mReferrerFillInIntent, + fillInIntent, + flags); + } - mChooserTargetComponentName = other.mChooserTargetComponentName; - mChooserTargetUnsanitizedTitle = other.mChooserTargetUnsanitizedTitle; - mChooserTargetIcon = other.mChooserTargetIcon; - mChooserTargetIntentExtras = other.mChooserTargetIntentExtras; + @Override + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return new SelectableTargetInfo(this, fillInIntent, flags); + } - mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle); + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return mHashProvider.getHashedTargetIdForMetrics(context); } @Override @@ -166,7 +297,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public boolean isSuspended() { - return (mSourceInfo != null) && mSourceInfo.isSuspended(); + return mIsSuspended; } @Override @@ -187,13 +318,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Override public ComponentName getResolvedComponentName() { - if (mSourceInfo != null) { - return mSourceInfo.getResolvedComponentName(); - } else if (mBackupResolveInfo != null) { - return new ComponentName(mBackupResolveInfo.activityInfo.packageName, - mBackupResolveInfo.activityInfo.name); - } - return null; + return mResolvedComponentName; } @Override @@ -206,58 +331,24 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mChooserTargetIcon; } - private Intent getBaseIntentToSend() { - Intent result = getResolvedIntent(); - if (result == null) { - Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); - } else { - result = new Intent(result); - if (mFillInIntent != null) { - result.fillIn(mFillInIntent, mFillInFlags); - } - result.fillIn(mReferrerFillInIntent, 0); - } - return result; - } - @Override public boolean start(Activity activity, Bundle options) { - throw new RuntimeException("ChooserTargets should be started as caller."); + return mActivityStarter.start(activity, options); } @Override public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - final Intent intent = getBaseIntentToSend(); - if (intent == null) { - return false; - } - intent.setComponent(getChooserTargetComponentName()); - intent.putExtras(mChooserTargetIntentExtras); - TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); - - // Important: we will ignore the target security checks in ActivityManager - // if and only if the ChooserTarget's target package is the same package - // where we got the ChooserTargetService that provided it. This lets a - // ChooserTargetService provide a non-exported or permission-guarded target - // to the chooser for the user to pick. - // - // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere - // so we'll obey the caller's normal security checks. - final boolean ignoreTargetSecurity = mSourceInfo != null - && mSourceInfo.getResolvedComponentName().getPackageName() - .equals(getChooserTargetComponentName().getPackageName()); - activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); - return true; + return mActivityStarter.startAsCaller(activity, options, userId); } @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - throw new RuntimeException("ChooserTargets should be started as caller."); + return mActivityStarter.startAsUser(activity, options, user); } @Override public ResolveInfo getResolveInfo() { - return mSourceInfo != null ? mSourceInfo.getResolveInfo() : mBackupResolveInfo; + return mResolveInfo; } @Override @@ -272,12 +363,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { } @Override - public Drawable getDisplayIcon() { - return mDisplayIcon; - } - - public void setDisplayIcon(Drawable icon) { - mDisplayIcon = icon; + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; } @Override @@ -292,19 +379,9 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mAppTarget; } - @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new SelectableTargetInfo(this, fillInIntent, flags); - } - @Override public List getAllSourceIntents() { - final List results = new ArrayList<>(); - if (mSourceInfo != null) { - // We only queried the service for the first one in our sourceinfo. - results.add(mSourceInfo.getAllSourceIntents().get(0)); - } - return results; + return mAllSourceIntents; } @Override @@ -312,21 +389,49 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mIsPinned; } - @Override - public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { - final String plaintext = - getChooserTargetComponentName().getPackageName() - + mChooserTargetUnsanitizedTitle; - return HashedStringCache.getInstance().hashString( - context, - HASHED_STRING_CACHE_TAG, - plaintext, - mMaxHashSaltDays); - } - private static String sanitizeDisplayLabel(CharSequence label) { SpannableStringBuilder sb = new SpannableStringBuilder(label); sb.clearSpans(); return sb.toString(); } + + private static List getAllSourceIntents(@Nullable DisplayResolveInfo sourceInfo) { + final List results = new ArrayList<>(); + if (sourceInfo != null) { + // We only queried the service for the first one in our sourceinfo. + results.add(sourceInfo.getAllSourceIntents().get(0)); + } + return results; + } + + private static ComponentName getResolvedComponentName( + @Nullable DisplayResolveInfo sourceInfo, ResolveInfo backupResolveInfo) { + if (sourceInfo != null) { + return sourceInfo.getResolvedComponentName(); + } else if (backupResolveInfo != null) { + return new ComponentName( + backupResolveInfo.activityInfo.packageName, + backupResolveInfo.activityInfo.name); + } + return null; + } + + @Nullable + private static Intent getBaseIntentToSend( + @Nullable Intent resolvedIntent, + Intent fillInIntent, + int fillInFlags, + Intent referrerFillInIntent) { + Intent result = resolvedIntent; + if (result == null) { + Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); + } else { + result = new Intent(result); + if (fillInIntent != null) { + result.fillIn(fillInIntent, fillInFlags); + } + result.fillIn(referrerFillInIntent, 0); + } + return result; + } } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 0e100d4f..72dd1b0b 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -42,6 +42,42 @@ import java.util.Objects; * A single target as represented in the chooser. */ public interface TargetInfo { + + /** + * Container for a {@link TargetInfo}'s (potentially) mutable icon state. This is provided to + * encapsulate the state so that the {@link TargetInfo} itself can be "immutable" (in some + * sense) as long as it always returns the same {@link IconHolder} instance. + * + * TODO: move "stateful" responsibilities out to clients; for more info see the Javadoc comment + * on {@link #getDisplayIconHolder()}. + */ + interface IconHolder { + /** @return the icon (if it's already loaded, or statically available), or null. */ + @Nullable + Drawable getDisplayIcon(); + + /** + * @param icon the icon to return on subsequent calls to {@link #getDisplayIcon()}. + * Implementations may discard this request as a no-op if they don't support setting. + */ + void setDisplayIcon(Drawable icon); + } + + /** A simple mutable-container implementation of {@link IconHolder}. */ + final class SettableIconHolder implements IconHolder { + @Nullable + private Drawable mDisplayIcon; + + @Nullable + public Drawable getDisplayIcon() { + return mDisplayIcon; + } + + public void setDisplayIcon(Drawable icon) { + mDisplayIcon = icon; + } + } + /** * Get the resolved intent that represents this target. Note that this may not be the * intent that will be launched by calling one of the start methods provided; @@ -135,16 +171,21 @@ public interface TargetInfo { CharSequence getExtendedInfo(); /** - * @return The drawable that should be used to represent this target including badge + * @return the {@link IconHolder} for the icon used to represent this target, including badge. + * + * TODO: while the {@link TargetInfo} may be immutable in always returning the same instance of + * {@link IconHolder} here, the holder itself is mutable state, and could become a problem if we + * ever rely on {@link TargetInfo} immutability elsewhere. Ideally, the {@link TargetInfo} + * should provide an immutable "spec" that tells clients how to load the appropriate + * icon, while leaving the load itself to some external component. */ - @Nullable - Drawable getDisplayIcon(); + IconHolder getDisplayIconHolder(); /** * @return true if display icon is available. */ default boolean hasDisplayIcon() { - return getDisplayIcon() != null; + return getDisplayIconHolder().getDisplayIcon() != null; } /** * Clone this target with the given fill-in information. diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index d054e7fa..080708fe 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -27,6 +27,7 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask +import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.TargetInfo import com.android.internal.R @@ -112,9 +113,11 @@ class ChooserListAdapterTest { verify(testTaskProvider, times(1)).invoke() } - private fun createSelectableTargetInfo(): TargetInfo = - SelectableTargetInfo.newSelectableTargetInfo( - /* sourceInfo = */ mock(), + private fun createSelectableTargetInfo(): TargetInfo { + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) + return SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ displayInfo, /* backupResolveInfo = */ mock(), /* resolvedIntent = */ Intent(), /* chooserTarget = */ createChooserTarget( @@ -125,6 +128,7 @@ class ChooserListAdapterTest { /* appTarget */ null, /* referrerFillInIntent = */ Intent() ) + } private fun createView(): View { val view = FrameLayout(context) diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 2c56e613..e114d38d 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -20,6 +20,7 @@ import android.content.ComponentName import android.content.Context import android.content.pm.ShortcutInfo import android.service.chooser.ChooserTarget +import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.TargetInfo import androidx.test.filters.SmallTest import org.junit.Assert.assertEquals @@ -59,9 +60,11 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ mock(), + /* origTarget = */ displayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -91,9 +94,11 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ true ) + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ mock(), + /* origTarget = */ displayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -123,9 +128,11 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ mock(), + /* origTarget = */ displayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -157,9 +164,13 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ true ) + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) + val displayInfo2: DisplayResolveInfo = mock() + whenever(displayInfo2.getAllSourceIntents()).thenReturn(listOf(mock())) - testSubject.addServiceResults( - /* origTarget = */ mock(), + val isUpdated = testSubject.addServiceResults( + /* origTarget = */ displayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(pkgAsc1, pkgAsc2), /* isShortcutResult = */ true, @@ -172,7 +183,7 @@ class ShortcutSelectionLogicTest { /* serviceTargets = */ serviceResults ) testSubject.addServiceResults( - /* origTarget = */ mock(), + /* origTarget = */ displayInfo2, /* origTargetScore = */ 0.2f, /* targets = */ listOf(pkgBsc1, pkgBsc2), /* isShortcutResult = */ true, @@ -201,9 +212,11 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) + val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ mock(), + /* origTarget = */ displayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 11837e08..c29de0be 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -28,6 +28,7 @@ import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -40,7 +41,7 @@ class TargetInfoTest { assertThat(info.isEmptyTargetInfo()).isTrue() assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isFalse() - assertThat(info.getDisplayIcon()).isNull() + assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() } @Test @@ -55,6 +56,7 @@ class TargetInfoTest { @Test fun testNewSelectableTargetInfo() { val displayInfo: DisplayResolveInfo = mock() + whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val chooserTarget = createChooserTarget( "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) -- cgit v1.2.3-59-g8ed1b From d5eb50ac083b03edf84c904e2ec16acb6ca50fdd Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 21 Nov 2022 12:38:14 -0500 Subject: Extract ChooserGridAdapter. As of ag/20463973 there's no major design changes required to lift this out of being an inner class, and this combines with ag/20455546 to pull *most* UI considerations out of `ChooserActivity` (namely, any that involve our "grid" except where those considerations are bridged across the `ChooserGridAdapter.ChooserActivityDelegate`). The testing changes were probably part of another CL that got included here accidentally, but they're improvements we can go ahead with anyways (switching some uses of mocks to real objects). Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I4948bcd1fa58d4dbe44f7aef009db5f8864882de --- .../android/intentresolver/ChooserActivity.java | 581 +------------------- .../android/intentresolver/ChooserListAdapter.java | 2 +- .../ChooserMultiProfilePagerAdapter.java | 27 +- .../intentresolver/grid/ChooserGridAdapter.java | 604 +++++++++++++++++++++ .../intentresolver/grid/DirectShareViewHolder.java | 2 +- .../intentresolver/ChooserListAdapterTest.kt | 16 +- .../intentresolver/ChooserWrapperActivity.java | 1 + .../intentresolver/ShortcutSelectionLogicTest.kt | 46 +- .../intentresolver/chooser/TargetInfoTest.kt | 20 +- 9 files changed, 670 insertions(+), 629 deletions(-) create mode 100644 java/src/com/android/intentresolver/grid/ChooserGridAdapter.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c3864480..4682ec50 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -26,9 +26,6 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; @@ -58,7 +55,6 @@ import android.content.pm.ShortcutInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; -import android.database.DataSetObserver; import android.graphics.Bitmap; import android.graphics.Insets; import android.graphics.drawable.Drawable; @@ -85,18 +81,14 @@ import android.util.Slog; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; -import android.view.View.MeasureSpec; -import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; -import android.view.animation.DecelerateInterpolator; import android.view.animation.LinearInterpolator; import android.widget.Button; -import android.widget.Space; import android.widget.TextView; import androidx.annotation.MainThread; @@ -111,12 +103,8 @@ import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; -import com.android.intentresolver.grid.FooterViewHolder; -import com.android.intentresolver.grid.ItemGroupViewHolder; -import com.android.intentresolver.grid.ItemViewHolder; -import com.android.intentresolver.grid.SingleRowViewHolder; -import com.android.intentresolver.grid.ViewHolderBase; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; @@ -130,8 +118,6 @@ import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.google.android.collect.Lists; - import java.io.File; import java.io.IOException; import java.lang.annotation.Retention; @@ -220,12 +206,6 @@ public class ChooserActivity extends ResolverActivity implements @Retention(RetentionPolicy.SOURCE) public @interface ShareTargetType {} - /** - * The transition time between placeholders for direct share to a message - * indicating that non are available. - */ - public static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; - public static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; @@ -2260,565 +2240,6 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * Adapter for all types of items and targets in ShareSheet. - * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the - * row level by this adapter but not on the item level. Individual targets within the row are - * handled by {@link ChooserListAdapter} - */ - @VisibleForTesting - public static final class ChooserGridAdapter extends - RecyclerView.Adapter { - - /** - * Injectable interface for any considerations that should be delegated to other components - * in the {@link ChooserActivity}. - * TODO: determine whether any of these methods return parameters that can safely be - * precomputed; whether any should be converted to `ChooserGridAdapter` setters to be - * invoked by external callbacks; and whether any reflect requirements that should be moved - * out of `ChooserGridAdapter` altogether. - */ - interface ChooserActivityDelegate { - /** @return whether we're showing a tabbed (multi-profile) UI. */ - boolean shouldShowTabs(); - - /** - * @return a content preview {@link View} that's appropriate for the caller's share - * content, constructed for display in the provided {@code parent} group. - */ - View buildContentPreview(ViewGroup parent); - - /** Notify the client that the item with the selected {@code itemIndex} was selected. */ - void onTargetSelected(int itemIndex); - - /** - * Notify the client that the item with the selected {@code itemIndex} was - * long-pressed. - */ - void onTargetLongPressed(int itemIndex); - - /** - * Notify the client that the provided {@code View} should be configured as the new - * "profile view" button. Callers should attach their own click listeners to implement - * behaviors on this view. - */ - void updateProfileViewButton(View newButtonFromProfileRow); - - /** - * @return the number of "valid" targets in the active list adapter. - * TODO: define "valid." - */ - int getValidTargetCount(); - - /** - * Request that the client update our {@code directShareGroup} to match their desired - * state for the "expansion" UI. - */ - void updateDirectShareExpansion(DirectShareViewHolder directShareGroup); - - /** - * Request that the client handle a scroll event that should be taken as expanding the - * provided {@code directShareGroup}. Note that this currently never happens due to a - * hard-coded condition in {@link #canExpandDirectShare()}. - */ - void handleScrollToExpandDirectShare( - DirectShareViewHolder directShareGroup, int y, int oldy); - } - - private static final int VIEW_TYPE_DIRECT_SHARE = 0; - private static final int VIEW_TYPE_NORMAL = 1; - private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; - private static final int VIEW_TYPE_PROFILE = 3; - private static final int VIEW_TYPE_AZ_LABEL = 4; - private static final int VIEW_TYPE_CALLER_AND_RANK = 5; - private static final int VIEW_TYPE_FOOTER = 6; - - private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; - - private final ChooserActivityDelegate mChooserActivityDelegate; - private final ChooserListAdapter mChooserListAdapter; - private final LayoutInflater mLayoutInflater; - - private final int mMaxTargetsPerRow; - private final boolean mShouldShowContentPreview; - private final int mChooserWidthPixels; - private final int mChooserRowTextOptionTranslatePixelSize; - private final boolean mShowAzLabelIfPoss; - - private DirectShareViewHolder mDirectShareViewHolder; - private int mChooserTargetWidth = 0; - - private int mFooterHeight = 0; - - ChooserGridAdapter( - Context context, - ChooserActivityDelegate chooserActivityDelegate, - ChooserListAdapter wrappedAdapter, - boolean shouldShowContentPreview, - int maxTargetsPerRow, - int numSheetExpansions) { - super(); - - mChooserActivityDelegate = chooserActivityDelegate; - - mChooserListAdapter = wrappedAdapter; - mLayoutInflater = LayoutInflater.from(context); - - mShouldShowContentPreview = shouldShowContentPreview; - mMaxTargetsPerRow = maxTargetsPerRow; - - mChooserWidthPixels = context.getResources().getDimensionPixelSize( - R.dimen.chooser_width); - mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( - R.dimen.chooser_row_text_option_translate); - - mShowAzLabelIfPoss = numSheetExpansions < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; - - wrappedAdapter.registerDataSetObserver(new DataSetObserver() { - @Override - public void onChanged() { - super.onChanged(); - notifyDataSetChanged(); - } - - @Override - public void onInvalidated() { - super.onInvalidated(); - notifyDataSetChanged(); - } - }); - } - - public void setFooterHeight(int height) { - mFooterHeight = height; - } - - /** - * Calculate the chooser target width to maximize space per item - * - * @param width The new row width to use for recalculation - * @return true if the view width has changed - */ - public boolean calculateChooserTargetWidth(int width) { - if (width == 0) { - return false; - } - - // Limit width to the maximum width of the chooser activity - int maxWidth = mChooserWidthPixels; - width = Math.min(maxWidth, width); - - int newWidth = width / mMaxTargetsPerRow; - if (newWidth != mChooserTargetWidth) { - mChooserTargetWidth = newWidth; - return true; - } - - return false; - } - - public int getRowCount() { - return (int) ( - getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() - + getCallerAndRankedTargetRowCount() - + getAzLabelRowCount() - + Math.ceil( - (float) mChooserListAdapter.getAlphaTargetCount() - / mMaxTargetsPerRow) - ); - } - - /** - * Whether the "system" row of targets is displayed. - * This area includes the content preview (if present) and action row. - */ - public int getSystemRowCount() { - // For the tabbed case we show the sticky content preview above the tabs, - // please refer to shouldShowStickyContentPreview - if (mChooserActivityDelegate.shouldShowTabs()) { - return 0; - } - - if (!mShouldShowContentPreview) { - return 0; - } - - if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { - return 0; - } - - return 1; - } - - public int getProfileRowCount() { - if (mChooserActivityDelegate.shouldShowTabs()) { - return 0; - } - return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; - } - - public int getFooterRowCount() { - return 1; - } - - public int getCallerAndRankedTargetRowCount() { - return (int) Math.ceil( - ((float) mChooserListAdapter.getCallerTargetCount() - + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow); - } - - // There can be at most one row in the listview, that is internally - // a ViewGroup with 2 rows - public int getServiceTargetRowCount() { - if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) { - return 1; - } - return 0; - } - - public int getAzLabelRowCount() { - // Only show a label if the a-z list is showing - return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0; - } - - @Override - public int getItemCount() { - return (int) ( - getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() - + getCallerAndRankedTargetRowCount() - + getAzLabelRowCount() - + mChooserListAdapter.getAlphaTargetCount() - + getFooterRowCount() - ); - } - - @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - switch (viewType) { - case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder( - mChooserActivityDelegate.buildContentPreview(parent), - viewType, - null, - null); - case VIEW_TYPE_PROFILE: - return new ItemViewHolder( - createProfileView(parent), - viewType, - null, - null); - case VIEW_TYPE_AZ_LABEL: - return new ItemViewHolder( - createAzLabelView(parent), - viewType, - null, - null); - case VIEW_TYPE_NORMAL: - return new ItemViewHolder( - mChooserListAdapter.createView(parent), - viewType, - mChooserActivityDelegate::onTargetSelected, - mChooserActivityDelegate::onTargetLongPressed); - case VIEW_TYPE_DIRECT_SHARE: - case VIEW_TYPE_CALLER_AND_RANK: - return createItemGroupViewHolder(viewType, parent); - case VIEW_TYPE_FOOTER: - Space sp = new Space(parent.getContext()); - sp.setLayoutParams(new RecyclerView.LayoutParams( - LayoutParams.MATCH_PARENT, mFooterHeight)); - return new FooterViewHolder(sp, viewType); - default: - // Since we catch all possible viewTypes above, no chance this is being called. - return null; - } - } - - @Override - public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { - int viewType = ((ViewHolderBase) holder).getViewType(); - switch (viewType) { - case VIEW_TYPE_DIRECT_SHARE: - case VIEW_TYPE_CALLER_AND_RANK: - bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder); - break; - case VIEW_TYPE_NORMAL: - bindItemViewHolder(position, (ItemViewHolder) holder); - break; - default: - } - } - - @Override - public int getItemViewType(int position) { - int count; - - int countSum = (count = getSystemRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; - - countSum += (count = getProfileRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; - - countSum += (count = getServiceTargetRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; - - countSum += (count = getCallerAndRankedTargetRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK; - - countSum += (count = getAzLabelRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL; - - if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER; - - return VIEW_TYPE_NORMAL; - } - - public int getTargetType(int position) { - return mChooserListAdapter.getPositionTargetType(getListPosition(position)); - } - - private View createProfileView(ViewGroup parent) { - View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); - mChooserActivityDelegate.updateProfileViewButton(profileRow); - return profileRow; - } - - private View createAzLabelView(ViewGroup parent) { - return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); - } - - private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) { - final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, - MeasureSpec.EXACTLY); - int columnCount = holder.getColumnCount(); - - final boolean isDirectShare = holder instanceof DirectShareViewHolder; - - for (int i = 0; i < columnCount; i++) { - final View v = mChooserListAdapter.createView(holder.getRowByIndex(i)); - final int column = i; - v.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column)); - } - }); - - // Show menu for both direct share and app share targets after long click. - v.setOnLongClickListener(v1 -> { - mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column)); - return true; - }); - - holder.addView(i, v); - - // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll = - // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be - // done before measuring. - if (isDirectShare) { - final ViewHolder vh = (ViewHolder) v.getTag(); - vh.text.setLines(2); - vh.text.setHorizontallyScrolling(false); - vh.text2.setVisibility(View.GONE); - } - - // Force height to be a given so we don't have visual disruption during scaling. - v.measure(exactSpec, spec); - setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight()); - } - - final ViewGroup viewGroup = holder.getViewGroup(); - - // Pre-measure and fix height so we can scale later. - holder.measure(); - setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight()); - - if (isDirectShare) { - DirectShareViewHolder dsvh = (DirectShareViewHolder) holder; - setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); - setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); - } - - viewGroup.setTag(holder); - return holder; - } - - private void setViewBounds(View view, int widthPx, int heightPx) { - LayoutParams lp = view.getLayoutParams(); - if (lp == null) { - lp = new LayoutParams(widthPx, heightPx); - view.setLayoutParams(lp); - } else { - lp.height = heightPx; - lp.width = widthPx; - } - } - - ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) { - if (viewType == VIEW_TYPE_DIRECT_SHARE) { - ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate( - R.layout.chooser_row_direct_share, parent, false); - ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, - parentGroup, false); - ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, - parentGroup, false); - parentGroup.addView(row1); - parentGroup.addView(row2); - - mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, - Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, - mChooserActivityDelegate::getValidTargetCount); - loadViewsIntoGroup(mDirectShareViewHolder); - - return mDirectShareViewHolder; - } else { - ViewGroup row = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, parent, - false); - ItemGroupViewHolder holder = - new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType); - loadViewsIntoGroup(holder); - - return holder; - } - } - - /** - * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from - * showing on top of the AZ list if the AZ label is visible. All other types are placed into - * their own row as determined by their target type, and dividers are added in the list to - * separate each type. - */ - int getRowType(int rowPosition) { - // Merge caller and ranked standard into a single row - int positionType = mChooserListAdapter.getPositionTargetType(rowPosition); - if (positionType == ChooserListAdapter.TARGET_CALLER) { - return ChooserListAdapter.TARGET_STANDARD; - } - - // If an the A-Z label is shown, prevent a separator from appearing by making the A-Z - // row type the same as the suggestion row type - if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) { - return ChooserListAdapter.TARGET_STANDARD; - } - - return positionType; - } - - void bindItemViewHolder(int position, ItemViewHolder holder) { - View v = holder.itemView; - int listPosition = getListPosition(position); - holder.setListPosition(listPosition); - mChooserListAdapter.bindView(listPosition, v); - } - - void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) { - final ViewGroup viewGroup = (ViewGroup) holder.itemView; - int start = getListPosition(position); - int startType = getRowType(start); - - int columnCount = holder.getColumnCount(); - int end = start + columnCount - 1; - while (getRowType(end) != startType && end >= start) { - end--; - } - - if (end == start && mChooserListAdapter.getItem(start).isEmptyTargetInfo()) { - final TextView textView = viewGroup.findViewById(com.android.internal.R.id.chooser_row_text_option); - - if (textView.getVisibility() != View.VISIBLE) { - textView.setAlpha(0.0f); - textView.setVisibility(View.VISIBLE); - textView.setText(R.string.chooser_no_direct_share_targets); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - - textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize); - ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY", - 0.0f); - translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - - AnimatorSet animSet = new AnimatorSet(); - animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS); - animSet.playTogether(fadeAnim, translateAnim); - animSet.start(); - } - } - - for (int i = 0; i < columnCount; i++) { - final View v = holder.getView(i); - - if (start + i <= end) { - holder.setViewVisibility(i, View.VISIBLE); - holder.setItemIndex(i, start + i); - mChooserListAdapter.bindView(holder.getItemIndex(i), v); - } else { - holder.setViewVisibility(i, View.INVISIBLE); - } - } - } - - int getListPosition(int position) { - position -= getSystemRowCount() + getProfileRowCount(); - - final int serviceCount = mChooserListAdapter.getServiceTargetCount(); - final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); - if (position < serviceRows) { - return position * mMaxTargetsPerRow; - } - - position -= serviceRows; - - final int callerAndRankedCount = mChooserListAdapter.getCallerTargetCount() - + mChooserListAdapter.getRankedTargetCount(); - final int callerAndRankedRows = getCallerAndRankedTargetRowCount(); - if (position < callerAndRankedRows) { - return serviceCount + position * mMaxTargetsPerRow; - } - - position -= getAzLabelRowCount() + callerAndRankedRows; - - return callerAndRankedCount + serviceCount + position; - } - - public void handleScroll(View v, int y, int oldy) { - boolean canExpandDirectShare = canExpandDirectShare(); - if (mDirectShareViewHolder != null && canExpandDirectShare) { - mChooserActivityDelegate.handleScrollToExpandDirectShare( - mDirectShareViewHolder, y, oldy); - } - } - - /** - * Only expand direct share area if there is a minimum number of targets. - */ - private boolean canExpandDirectShare() { - // Do not enable until we have confirmed more apps are using sharing shortcuts - // Check git history for enablement logic - return false; - } - - public ChooserListAdapter getListAdapter() { - return mChooserListAdapter; - } - - boolean shouldCellSpan(int position) { - return getItemViewType(position) == VIEW_TYPE_NORMAL; - } - - void updateDirectShareExpansion() { - if (mDirectShareViewHolder == null || !canExpandDirectShare()) { - return; - } - mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder); - } - } - static class ChooserTargetRankingInfo { public final List scores; public final UserHandle userHandle; diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 6d59a680..91a701a6 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -413,7 +413,7 @@ public class ChooserListAdapter extends ResolverListAdapter { return 0; } - int getAlphaTargetCount() { + public int getAlphaTargetCount() { int groupedCount = mSortedList.size(); int ungroupedCount = mCallerTargets.size() + getDisplayResolveInfoCount(); return (ungroupedCount > mMaxRankedTargets) ? groupedCount : 0; diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index d0463fff..93daa299 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.internal.annotations.VisibleForTesting; /** @@ -40,8 +41,9 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd private int mBottomOffset; private int mMaxTargetsPerRow; - ChooserMultiProfilePagerAdapter(Context context, - ChooserActivity.ChooserGridAdapter adapter, + ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, QuietModeManager quietModeManager, UserHandle workProfileUserHandle, @@ -54,9 +56,10 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd mMaxTargetsPerRow = maxTargetsPerRow; } - ChooserMultiProfilePagerAdapter(Context context, - ChooserActivity.ChooserGridAdapter personalAdapter, - ChooserActivity.ChooserGridAdapter workAdapter, + ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter personalAdapter, + ChooserGridAdapter workAdapter, EmptyStateProvider emptyStateProvider, QuietModeManager quietModeManager, @Profile int defaultProfile, @@ -71,8 +74,7 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd mMaxTargetsPerRow = maxTargetsPerRow; } - private ChooserProfileDescriptor createProfileDescriptor( - ChooserActivity.ChooserGridAdapter adapter) { + private ChooserProfileDescriptor createProfileDescriptor(ChooserGridAdapter adapter) { final LayoutInflater inflater = LayoutInflater.from(getContext()); final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); @@ -103,7 +105,7 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd @Override @VisibleForTesting - public ChooserActivity.ChooserGridAdapter getAdapterForIndex(int pageIndex) { + public ChooserGridAdapter getAdapterForIndex(int pageIndex) { return mItems[pageIndex].chooserGridAdapter; } @@ -122,8 +124,7 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd @Override void setupListAdapter(int pageIndex) { final RecyclerView recyclerView = getItem(pageIndex).recyclerView; - ChooserActivity.ChooserGridAdapter chooserGridAdapter = - getItem(pageIndex).chooserGridAdapter; + ChooserGridAdapter chooserGridAdapter = getItem(pageIndex).chooserGridAdapter; GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); glm.setSpanCount(mMaxTargetsPerRow); glm.setSpanSizeLookup( @@ -164,7 +165,7 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd } @Override - ChooserActivity.ChooserGridAdapter getCurrentRootAdapter() { + ChooserGridAdapter getCurrentRootAdapter() { return getAdapterForIndex(getCurrentPage()); } @@ -195,9 +196,9 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd } class ChooserProfileDescriptor extends ProfileDescriptor { - private ChooserActivity.ChooserGridAdapter chooserGridAdapter; + private ChooserGridAdapter chooserGridAdapter; private RecyclerView recyclerView; - ChooserProfileDescriptor(ViewGroup rootView, ChooserActivity.ChooserGridAdapter adapter) { + ChooserProfileDescriptor(ViewGroup rootView, ChooserGridAdapter adapter) { super(rootView); chooserGridAdapter = adapter; recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java new file mode 100644 index 00000000..1cf59316 --- /dev/null +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -0,0 +1,604 @@ +/* + * Copyright (C) 2008 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.intentresolver.grid; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.app.ActivityManager; +import android.content.Context; +import android.database.DataSetObserver; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.animation.DecelerateInterpolator; +import android.widget.Space; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter.ViewHolder; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.android.collect.Lists; + +/** + * Adapter for all types of items and targets in ShareSheet. + * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the + * row level by this adapter but not on the item level. Individual targets within the row are + * handled by {@link ChooserListAdapter} + */ +@VisibleForTesting +public final class ChooserGridAdapter extends RecyclerView.Adapter { + + /** + * The transition time between placeholders for direct share to a message + * indicating that none are available. + */ + public static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; + + /** + * Injectable interface for any considerations that should be delegated to other components + * in the {@link ChooserActivity}. + * TODO: determine whether any of these methods return parameters that can safely be + * precomputed; whether any should be converted to `ChooserGridAdapter` setters to be + * invoked by external callbacks; and whether any reflect requirements that should be moved + * out of `ChooserGridAdapter` altogether. + */ + public interface ChooserActivityDelegate { + /** @return whether we're showing a tabbed (multi-profile) UI. */ + boolean shouldShowTabs(); + + /** + * @return a content preview {@link View} that's appropriate for the caller's share + * content, constructed for display in the provided {@code parent} group. + */ + View buildContentPreview(ViewGroup parent); + + /** Notify the client that the item with the selected {@code itemIndex} was selected. */ + void onTargetSelected(int itemIndex); + + /** + * Notify the client that the item with the selected {@code itemIndex} was + * long-pressed. + */ + void onTargetLongPressed(int itemIndex); + + /** + * Notify the client that the provided {@code View} should be configured as the new + * "profile view" button. Callers should attach their own click listeners to implement + * behaviors on this view. + */ + void updateProfileViewButton(View newButtonFromProfileRow); + + /** + * @return the number of "valid" targets in the active list adapter. + * TODO: define "valid." + */ + int getValidTargetCount(); + + /** + * Request that the client update our {@code directShareGroup} to match their desired + * state for the "expansion" UI. + */ + void updateDirectShareExpansion(DirectShareViewHolder directShareGroup); + + /** + * Request that the client handle a scroll event that should be taken as expanding the + * provided {@code directShareGroup}. Note that this currently never happens due to a + * hard-coded condition in {@link #canExpandDirectShare()}. + */ + void handleScrollToExpandDirectShare( + DirectShareViewHolder directShareGroup, int y, int oldy); + } + + private static final int VIEW_TYPE_DIRECT_SHARE = 0; + private static final int VIEW_TYPE_NORMAL = 1; + private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; + private static final int VIEW_TYPE_PROFILE = 3; + private static final int VIEW_TYPE_AZ_LABEL = 4; + private static final int VIEW_TYPE_CALLER_AND_RANK = 5; + private static final int VIEW_TYPE_FOOTER = 6; + + private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; + + private final ChooserActivityDelegate mChooserActivityDelegate; + private final ChooserListAdapter mChooserListAdapter; + private final LayoutInflater mLayoutInflater; + + private final int mMaxTargetsPerRow; + private final boolean mShouldShowContentPreview; + private final int mChooserWidthPixels; + private final int mChooserRowTextOptionTranslatePixelSize; + private final boolean mShowAzLabelIfPoss; + + private DirectShareViewHolder mDirectShareViewHolder; + private int mChooserTargetWidth = 0; + + private int mFooterHeight = 0; + + public ChooserGridAdapter( + Context context, + ChooserActivityDelegate chooserActivityDelegate, + ChooserListAdapter wrappedAdapter, + boolean shouldShowContentPreview, + int maxTargetsPerRow, + int numSheetExpansions) { + super(); + + mChooserActivityDelegate = chooserActivityDelegate; + + mChooserListAdapter = wrappedAdapter; + mLayoutInflater = LayoutInflater.from(context); + + mShouldShowContentPreview = shouldShowContentPreview; + mMaxTargetsPerRow = maxTargetsPerRow; + + mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); + mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( + R.dimen.chooser_row_text_option_translate); + + mShowAzLabelIfPoss = numSheetExpansions < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; + + wrappedAdapter.registerDataSetObserver(new DataSetObserver() { + @Override + public void onChanged() { + super.onChanged(); + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + notifyDataSetChanged(); + } + }); + } + + public void setFooterHeight(int height) { + mFooterHeight = height; + } + + /** + * Calculate the chooser target width to maximize space per item + * + * @param width The new row width to use for recalculation + * @return true if the view width has changed + */ + public boolean calculateChooserTargetWidth(int width) { + if (width == 0) { + return false; + } + + // Limit width to the maximum width of the chooser activity + int maxWidth = mChooserWidthPixels; + width = Math.min(maxWidth, width); + + int newWidth = width / mMaxTargetsPerRow; + if (newWidth != mChooserTargetWidth) { + mChooserTargetWidth = newWidth; + return true; + } + + return false; + } + + public int getRowCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + Math.ceil( + (float) mChooserListAdapter.getAlphaTargetCount() + / mMaxTargetsPerRow) + ); + } + + /** + * Whether the "system" row of targets is displayed. + * This area includes the content preview (if present) and action row. + */ + public int getSystemRowCount() { + // For the tabbed case we show the sticky content preview above the tabs, + // please refer to shouldShowStickyContentPreview + if (mChooserActivityDelegate.shouldShowTabs()) { + return 0; + } + + if (!mShouldShowContentPreview) { + return 0; + } + + if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { + return 0; + } + + return 1; + } + + public int getProfileRowCount() { + if (mChooserActivityDelegate.shouldShowTabs()) { + return 0; + } + return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; + } + + public int getFooterRowCount() { + return 1; + } + + public int getCallerAndRankedTargetRowCount() { + return (int) Math.ceil( + ((float) mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow); + } + + // There can be at most one row in the listview, that is internally + // a ViewGroup with 2 rows + public int getServiceTargetRowCount() { + if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) { + return 1; + } + return 0; + } + + public int getAzLabelRowCount() { + // Only show a label if the a-z list is showing + return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0; + } + + @Override + public int getItemCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + mChooserListAdapter.getAlphaTargetCount() + + getFooterRowCount() + ); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_CONTENT_PREVIEW: + return new ItemViewHolder( + mChooserActivityDelegate.buildContentPreview(parent), + viewType, + null, + null); + case VIEW_TYPE_PROFILE: + return new ItemViewHolder( + createProfileView(parent), + viewType, + null, + null); + case VIEW_TYPE_AZ_LABEL: + return new ItemViewHolder( + createAzLabelView(parent), + viewType, + null, + null); + case VIEW_TYPE_NORMAL: + return new ItemViewHolder( + mChooserListAdapter.createView(parent), + viewType, + mChooserActivityDelegate::onTargetSelected, + mChooserActivityDelegate::onTargetLongPressed); + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + return createItemGroupViewHolder(viewType, parent); + case VIEW_TYPE_FOOTER: + Space sp = new Space(parent.getContext()); + sp.setLayoutParams(new RecyclerView.LayoutParams( + LayoutParams.MATCH_PARENT, mFooterHeight)); + return new FooterViewHolder(sp, viewType); + default: + // Since we catch all possible viewTypes above, no chance this is being called. + return null; + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + int viewType = ((ViewHolderBase) holder).getViewType(); + switch (viewType) { + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder); + break; + case VIEW_TYPE_NORMAL: + bindItemViewHolder(position, (ItemViewHolder) holder); + break; + default: + } + } + + @Override + public int getItemViewType(int position) { + int count; + + int countSum = (count = getSystemRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; + + countSum += (count = getProfileRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; + + countSum += (count = getServiceTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; + + countSum += (count = getCallerAndRankedTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK; + + countSum += (count = getAzLabelRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL; + + if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER; + + return VIEW_TYPE_NORMAL; + } + + public int getTargetType(int position) { + return mChooserListAdapter.getPositionTargetType(getListPosition(position)); + } + + private View createProfileView(ViewGroup parent) { + View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); + mChooserActivityDelegate.updateProfileViewButton(profileRow); + return profileRow; + } + + private View createAzLabelView(ViewGroup parent) { + return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); + } + + private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, MeasureSpec.EXACTLY); + int columnCount = holder.getColumnCount(); + + final boolean isDirectShare = holder instanceof DirectShareViewHolder; + + for (int i = 0; i < columnCount; i++) { + final View v = mChooserListAdapter.createView(holder.getRowByIndex(i)); + final int column = i; + v.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column)); + } + }); + + // Show menu for both direct share and app share targets after long click. + v.setOnLongClickListener(v1 -> { + mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column)); + return true; + }); + + holder.addView(i, v); + + // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll = + // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be + // done before measuring. + if (isDirectShare) { + final ViewHolder vh = (ViewHolder) v.getTag(); + vh.text.setLines(2); + vh.text.setHorizontallyScrolling(false); + vh.text2.setVisibility(View.GONE); + } + + // Force height to be a given so we don't have visual disruption during scaling. + v.measure(exactSpec, spec); + setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight()); + } + + final ViewGroup viewGroup = holder.getViewGroup(); + + // Pre-measure and fix height so we can scale later. + holder.measure(); + setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight()); + + if (isDirectShare) { + DirectShareViewHolder dsvh = (DirectShareViewHolder) holder; + setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + } + + viewGroup.setTag(holder); + return holder; + } + + private void setViewBounds(View view, int widthPx, int heightPx) { + LayoutParams lp = view.getLayoutParams(); + if (lp == null) { + lp = new LayoutParams(widthPx, heightPx); + view.setLayoutParams(lp); + } else { + lp.height = heightPx; + lp.width = widthPx; + } + } + + ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) { + if (viewType == VIEW_TYPE_DIRECT_SHARE) { + ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row_direct_share, parent, false); + ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parentGroup, false); + ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parentGroup, false); + parentGroup.addView(row1); + parentGroup.addView(row2); + + mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, + Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, + mChooserActivityDelegate::getValidTargetCount); + loadViewsIntoGroup(mDirectShareViewHolder); + + return mDirectShareViewHolder; + } else { + ViewGroup row = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row, parent, false); + ItemGroupViewHolder holder = + new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType); + loadViewsIntoGroup(holder); + + return holder; + } + } + + /** + * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from + * showing on top of the AZ list if the AZ label is visible. All other types are placed into + * their own row as determined by their target type, and dividers are added in the list to + * separate each type. + */ + int getRowType(int rowPosition) { + // Merge caller and ranked standard into a single row + int positionType = mChooserListAdapter.getPositionTargetType(rowPosition); + if (positionType == ChooserListAdapter.TARGET_CALLER) { + return ChooserListAdapter.TARGET_STANDARD; + } + + // If an A-Z label is shown, prevent a separator from appearing by making the A-Z + // row type the same as the suggestion row type + if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) { + return ChooserListAdapter.TARGET_STANDARD; + } + + return positionType; + } + + void bindItemViewHolder(int position, ItemViewHolder holder) { + View v = holder.itemView; + int listPosition = getListPosition(position); + holder.setListPosition(listPosition); + mChooserListAdapter.bindView(listPosition, v); + } + + void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) { + final ViewGroup viewGroup = (ViewGroup) holder.itemView; + int start = getListPosition(position); + int startType = getRowType(start); + + int columnCount = holder.getColumnCount(); + int end = start + columnCount - 1; + while (getRowType(end) != startType && end >= start) { + end--; + } + + if (end == start && mChooserListAdapter.getItem(start).isEmptyTargetInfo()) { + final TextView textView = viewGroup.findViewById( + com.android.internal.R.id.chooser_row_text_option); + + if (textView.getVisibility() != View.VISIBLE) { + textView.setAlpha(0.0f); + textView.setVisibility(View.VISIBLE); + textView.setText(R.string.chooser_no_direct_share_targets); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize); + ValueAnimator translateAnim = + ObjectAnimator.ofFloat(textView, "translationY", 0.0f); + translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + AnimatorSet animSet = new AnimatorSet(); + animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.playTogether(fadeAnim, translateAnim); + animSet.start(); + } + } + + for (int i = 0; i < columnCount; i++) { + final View v = holder.getView(i); + + if (start + i <= end) { + holder.setViewVisibility(i, View.VISIBLE); + holder.setItemIndex(i, start + i); + mChooserListAdapter.bindView(holder.getItemIndex(i), v); + } else { + holder.setViewVisibility(i, View.INVISIBLE); + } + } + } + + int getListPosition(int position) { + position -= getSystemRowCount() + getProfileRowCount(); + + final int serviceCount = mChooserListAdapter.getServiceTargetCount(); + final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); + if (position < serviceRows) { + return position * mMaxTargetsPerRow; + } + + position -= serviceRows; + + final int callerAndRankedCount = + mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount(); + final int callerAndRankedRows = getCallerAndRankedTargetRowCount(); + if (position < callerAndRankedRows) { + return serviceCount + position * mMaxTargetsPerRow; + } + + position -= getAzLabelRowCount() + callerAndRankedRows; + + return callerAndRankedCount + serviceCount + position; + } + + public void handleScroll(View v, int y, int oldy) { + boolean canExpandDirectShare = canExpandDirectShare(); + if (mDirectShareViewHolder != null && canExpandDirectShare) { + mChooserActivityDelegate.handleScrollToExpandDirectShare( + mDirectShareViewHolder, y, oldy); + } + } + + /** Only expand direct share area if there is a minimum number of targets. */ + private boolean canExpandDirectShare() { + // Do not enable until we have confirmed more apps are using sharing shortcuts + // Check git history for enablement logic + return false; + } + + public ChooserListAdapter getListAdapter() { + return mChooserListAdapter; + } + + public boolean shouldCellSpan(int position) { + return getItemViewType(position) == VIEW_TYPE_NORMAL; + } + + public void updateDirectShareExpansion() { + if (mDirectShareViewHolder == null || !canExpandDirectShare()) { + return; + } + mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder); + } +} diff --git a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java index cfd54697..316c9f07 100644 --- a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java +++ b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java @@ -113,7 +113,7 @@ public class DirectShareViewHolder extends ItemGroupViewHolder { mCellVisibility[i] = false; ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); - fadeAnim.setDuration(ChooserActivity.NO_DIRECT_SHARE_ANIM_IN_MILLIS); + fadeAnim.setDuration(ChooserGridAdapter.NO_DIRECT_SHARE_ANIM_IN_MILLIS); fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); fadeAnim.addListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator animation) { diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 6184cd1c..58f6b733 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -115,11 +115,16 @@ class ChooserListAdapterTest { verify(testTaskProvider, times(1)).invoke() } - private fun createSelectableTargetInfo(): TargetInfo { - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) - return SelectableTargetInfo.newSelectableTargetInfo( - /* sourceInfo = */ displayInfo, + private fun createSelectableTargetInfo(): TargetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolverDataProvider.createResolveInfo(2, 0), + "label", + "extended info", + Intent(), + /* resolveInfoPresentationGetter= */ null + ), /* backupResolveInfo = */ mock(), /* resolvedIntent = */ Intent(), /* chooserTarget = */ createChooserTarget( @@ -130,7 +135,6 @@ class ChooserListAdapterTest { /* appTarget */ null, /* referrerFillInIntent = */ Intent() ) - } private fun createView(): View { val view = FrameLayout(context) diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8c842786..04e727ba 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -42,6 +42,7 @@ import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGet import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index e114d38d..a8d6f978 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -18,6 +18,8 @@ package com.android.intentresolver import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.pm.ResolveInfo import android.content.pm.ShortcutInfo import android.service.chooser.ChooserTarget import com.android.intentresolver.chooser.DisplayResolveInfo @@ -48,6 +50,22 @@ class ShortcutSelectionLogicTest { } } + private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolverDataProvider.createResolveInfo(3, 0), + "label", + "extended info", + Intent(), + /* resolveInfoPresentationGetter= */ null) + + private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolverDataProvider.createResolveInfo(4, 0), + "label 2", + "extended info 2", + Intent(), + /* resolveInfoPresentationGetter= */ null) + private operator fun Map>.get(pkg: String, idx: Int) = this[pkg]?.get(idx) ?: error("missing package $pkg") @@ -60,11 +78,9 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ displayInfo, + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -94,11 +110,9 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ true ) - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ displayInfo, + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -128,11 +142,9 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ displayInfo, + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -164,13 +176,9 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ true ) - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) - val displayInfo2: DisplayResolveInfo = mock() - whenever(displayInfo2.getAllSourceIntents()).thenReturn(listOf(mock())) - val isUpdated = testSubject.addServiceResults( - /* origTarget = */ displayInfo, + testSubject.addServiceResults( + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(pkgAsc1, pkgAsc2), /* isShortcutResult = */ true, @@ -183,7 +191,7 @@ class ShortcutSelectionLogicTest { /* serviceTargets = */ serviceResults ) testSubject.addServiceResults( - /* origTarget = */ displayInfo2, + /* origTarget = */ otherBaseDisplayInfo, /* origTargetScore = */ 0.2f, /* targets = */ listOf(pkgBsc1, pkgBsc2), /* isShortcutResult = */ true, @@ -212,11 +220,9 @@ class ShortcutSelectionLogicTest { /* maxShortcutTargetsPerApp = */ 1, /* applySharingAppLimits = */ false ) - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) val isUpdated = testSubject.addServiceResults( - /* origTarget = */ displayInfo, + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0.1f, /* targets = */ listOf(sc1, sc2), /* isShortcutResult = */ true, @@ -258,7 +264,7 @@ class ShortcutSelectionLogicTest { } testSubject.addServiceResults( - /* origTarget = */ null, + /* origTarget = */ baseDisplayInfo, /* origTargetScore = */ 0f, /* targets = */ listOf(sc1, sc2, sc3), /* isShortcutResult = */ false, diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index c29de0be..7c2b07a9 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -28,7 +28,6 @@ import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.ResolverDataProvider -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -55,20 +54,25 @@ class TargetInfoTest { @Test fun testNewSelectableTargetInfo() { - val displayInfo: DisplayResolveInfo = mock() - whenever(displayInfo.getAllSourceIntents()).thenReturn(listOf(mock())) + val resolvedIntent = Intent() + val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + ResolverDataProvider.createResolveInfo(1, 0), + "label", + "extended info", + resolvedIntent, + /* resolveInfoPresentationGetter= */ null) val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") - val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) + "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) val appTarget = AppTarget( AppTargetId("id"), chooserTarget.componentName.packageName, chooserTarget.componentName.className, UserHandle.CURRENT) - val resolvedIntent = mock() val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - displayInfo, + baseDisplayInfo, mock(), resolvedIntent, chooserTarget, @@ -79,7 +83,7 @@ class TargetInfoTest { ) assertThat(targetInfo.isSelectableTargetInfo).isTrue() assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. - assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(displayInfo) + assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo) assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo) -- cgit v1.2.3-59-g8ed1b From 9ebd81953ce60236381a068a0b7c52c8164bbfea Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 12 Dec 2022 16:26:16 -0500 Subject: Extract remainining ChooserActivity logging This especially covers other kinds of logging in ChooserActivity that hadn't previously been a responsibility of ChooserActivityLogger (e.g. because they instead went directly through MetricsLogger). There's a few reasons to make these changes, in rough priority order: 1. Tests can make assertions about the high-level logging methods we expect to call; the implementation of those methods (in terms of low-level event streams) is unit-tested for the logger itself. 2. We can encapsulate all the types of loggers (UiEventLogger, MetricsLogger, etc.) in a single object, which is easier to inject as a dependency as we continue decomposing ChooserActivity responsibilities into other components. 3. High-level logger APIs reduce the clutter that clients previously spent building up the low-level event data that's now abstracted away. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: Ib3197bca12ec5ea3c925946c0f6d37a6be19d8fa --- .../android/intentresolver/ChooserActivity.java | 178 +++++------------ .../intentresolver/ChooserActivityLogger.java | 145 ++++++++++++-- .../intentresolver/ChooserActivityLoggerTest.java | 199 +++++++++++++++++-- .../ChooserActivityOverrideData.java | 3 - .../intentresolver/ChooserWrapperActivity.java | 6 - .../UnbundledChooserActivityTest.java | 210 ++++++++------------- 6 files changed, 442 insertions(+), 299 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 4682ec50..6d5304d9 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -58,7 +58,6 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Insets; import android.graphics.drawable.Drawable; -import android.metrics.LogMaker; import android.net.Uri; import android.os.Bundle; import android.os.Environment; @@ -74,7 +73,6 @@ import android.provider.DeviceConfig; import android.provider.Settings; import android.service.chooser.ChooserTarget; import android.text.TextUtils; -import android.util.HashedStringCache; import android.util.Log; import android.util.Size; import android.util.Slog; @@ -114,7 +112,6 @@ import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; -import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; @@ -186,13 +183,6 @@ public class ChooserActivity extends ResolverActivity implements public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; - public static final int SELECTION_TYPE_SERVICE = 1; - public static final int SELECTION_TYPE_APP = 2; - public static final int SELECTION_TYPE_STANDARD = 3; - public static final int SELECTION_TYPE_COPY = 4; - public static final int SELECTION_TYPE_NEARBY = 5; - public static final int SELECTION_TYPE_EDIT = 6; - private static final int SCROLL_STATUS_IDLE = 0; private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; @@ -251,8 +241,6 @@ public class ChooserActivity extends ResolverActivity implements private SharedPreferences mPinnedSharedPrefs; private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; - protected MetricsLogger mMetricsLogger; - private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); @Nullable @@ -346,13 +334,8 @@ public class ChooserActivity extends ResolverActivity implements mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; - - getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) - .setSubtype(isWorkProfile() ? MetricsEvent.MANAGED_PROFILE : - MetricsEvent.PARENT_PROFILE) - .addTaggedData( - MetricsEvent.FIELD_SHARESHEET_MIMETYPE, mChooserRequest.getTargetType()) - .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); + getChooserActivityLogger().logChooserActivityShown( + isWorkProfile(), mChooserRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -593,7 +576,9 @@ public class ChooserActivity extends ResolverActivity implements if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { - logActionShareWithPreview(); + getChooserActivityLogger().logActionShareWithPreview( + ChooserContentPreviewUi.findPreferredContentPreview( + getTargetIntent(), getContentResolver(), this::isImageType)); } return postRebuildListInternal(rebuildCompleted); } @@ -682,15 +667,7 @@ public class ChooserActivity extends ResolverActivity implements Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); - // Log share completion via copy - LogMaker targetLogMaker = new LogMaker( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); - getMetricsLogger().write(targetLogMaker); - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_COPY, - "", - -1, - false); + getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); setResult(RESULT_OK); finish(); @@ -954,12 +931,8 @@ public class ChooserActivity extends ResolverActivity implements ti.getDisplayIconHolder().getDisplayIcon(), ti.getDisplayLabel(), (View unused) -> { - // Log share completion via nearby - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_NEARBY, - "", - -1, - false); + getChooserActivityLogger().logActionSelected( + ChooserActivityLogger.SELECTION_TYPE_NEARBY); // Action bar is user-independent, always start as primary safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); finish(); @@ -978,11 +951,8 @@ public class ChooserActivity extends ResolverActivity implements ti.getDisplayLabel(), (View unused) -> { // Log share completion via edit - getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_EDIT, - "", - -1, - false); + getChooserActivityLogger().logActionSelected( + ChooserActivityLogger.SELECTION_TYPE_EDIT); View firstImgView = getFirstVisibleImgPreviewView(); // Action bar is user-independent, always start as primary if (firstImgView == null) { @@ -1032,14 +1002,6 @@ public class ChooserActivity extends ResolverActivity implements return mimeType != null && mimeType.startsWith("image/"); } - private void logContentPreviewWarning(Uri uri) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " - + "desired, consider using Intent#createChooser to launch the ChooserActivity, " - + "and set your Intent's clipData and flags in accordance with that method's " - + "documentation"); - } - private int getNumSheetExpansions() { return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0); } @@ -1249,78 +1211,51 @@ public class ChooserActivity extends ResolverActivity implements super.startSelected(which, always, filtered); if (currentListAdapter.getCount() > 0) { - // Log the index of which type of target the user picked. - // Lower values mean the ranking was better. - int cat = 0; - int value = which; - int directTargetAlsoRanked = -1; - int numCallerProvided = 0; - HashedStringCache.HashResult directTargetHashed = null; switch (currentListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_SERVICE: - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; - directTargetHashed = targetInfo.getHashedTargetIdForMetrics(this); - directTargetAlsoRanked = getRankedPosition(targetInfo); - - numCallerProvided = mChooserRequest.getCallerChooserTargets().size(); getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_SERVICE, + ChooserActivityLogger.SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, - value, - targetInfo.isPinned() + which, + /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), + mChooserRequest.getCallerChooserTargets().size(), + targetInfo.getHashedTargetIdForMetrics(this), + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost ); - break; + return; case ChooserListAdapter.TARGET_CALLER: case ChooserListAdapter.TARGET_STANDARD: - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; - value -= currentListAdapter.getSurfacedTargetInfo().size(); - numCallerProvided = currentListAdapter.getCallerTargetCount(); getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_APP, + ChooserActivityLogger.SELECTION_TYPE_APP, targetInfo.getResolveInfo().activityInfo.processName, - value, - targetInfo.isPinned() + (which - currentListAdapter.getSurfacedTargetInfo().size()), + /* directTargetAlsoRanked= */ -1, + currentListAdapter.getCallerTargetCount(), + /* directTargetHashed= */ null, + targetInfo.isPinned(), + mIsSuccessfullySelected, + selectionCost ); - break; + return; case ChooserListAdapter.TARGET_STANDARD_AZ: - // A-Z targets are unranked standard targets; we use -1 to mark that they - // are from the alphabetical pool. - value = -1; - cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + // A-Z targets are unranked standard targets; we use a value of -1 to mark that + // they are from the alphabetical pool. + // TODO: why do we log a different selection type if the -1 value already + // designates the same condition? getChooserActivityLogger().logShareTargetSelected( - SELECTION_TYPE_STANDARD, + ChooserActivityLogger.SELECTION_TYPE_STANDARD, targetInfo.getResolveInfo().activityInfo.processName, - value, - false + /* value= */ -1, + /* directTargetAlsoRanked= */ -1, + /* numCallerProvided= */ 0, + /* directTargetHashed= */ null, + /* isPinned= */ false, + mIsSuccessfullySelected, + selectionCost ); - break; - } - - if (cat != 0) { - LogMaker targetLogMaker = new LogMaker(cat).setSubtype(value); - if (directTargetHashed != null) { - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); - targetLogMaker.addTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, - directTargetHashed.saltGeneration); - targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, - directTargetAlsoRanked); - } - targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, - numCallerProvided); - getMetricsLogger().write(targetLogMaker); - } - - if (mIsSuccessfullySelected) { - if (DEBUG) { - Log.d(TAG, "User Selection Time Cost is " + selectionCost); - Log.d(TAG, "position of selected app/service/caller is " + - Integer.toString(value)); - } - MetricsLogger.histogram(null, "user_selection_cost_for_smart_sharing", - (int) selectionCost); - MetricsLogger.histogram(null, "app_position_for_smart_sharing", value); + return; } } } @@ -1396,15 +1331,14 @@ public class ChooserActivity extends ResolverActivity implements } } - private void logDirectShareTargetReceived(int logCategory, UserHandle forUser) { + private void logDirectShareTargetReceived(UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { return; } - - final int apiLatency = - (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime); - getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); + getChooserActivityLogger().logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, + (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); } void updateModelAndChooserCounts(TargetInfo info) { @@ -1546,13 +1480,6 @@ public class ChooserActivity extends ResolverActivity implements } } - protected MetricsLogger getMetricsLogger() { - if (mMetricsLogger == null) { - mMetricsLogger = new MetricsLogger(); - } - return mMetricsLogger; - } - protected ChooserActivityLogger getChooserActivityLogger() { if (mChooserActivityLogger == null) { mChooserActivityLogger = new ChooserActivityLogger(); @@ -1736,7 +1663,7 @@ public class ChooserActivity extends ResolverActivity implements try { return getContentResolver().loadThumbnail(uri, size, null); } catch (IOException | NullPointerException | SecurityException ex) { - logContentPreviewWarning(uri); + getChooserActivityLogger().logContentPreviewWarning(uri); } return null; } @@ -1996,10 +1923,7 @@ public class ChooserActivity extends ResolverActivity implements adapter.completeServiceTargetLoading(); } - logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, - userHandle); - + logDirectShareTargetReceived(userHandle); sendVoiceChoicesIfNeeded(); getChooserActivityLogger().logSharesheetDirectLoadComplete(); } @@ -2130,14 +2054,6 @@ public class ChooserActivity extends ResolverActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private void logActionShareWithPreview() { - Intent targetIntent = getTargetIntent(); - int previewType = ChooserContentPreviewUi.findPreferredContentPreview( - targetIntent, getContentResolver(), this::isImageType); - getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW) - .setSubtype(previewType)); - } - private void startFinishAnimation() { View rootView = findRootView(); if (rootView != null) { diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 811d5f3e..9109bf93 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -16,15 +16,22 @@ package com.android.intentresolver; +import android.annotation.Nullable; import android.content.Intent; +import android.metrics.LogMaker; +import android.net.Uri; import android.provider.MediaStore; +import android.util.HashedStringCache; +import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; import com.android.internal.logging.UiEventLoggerImpl; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; /** @@ -32,6 +39,16 @@ import com.android.internal.util.FrameworkStatsLog; * @hide */ public class ChooserActivityLogger { + private static final String TAG = "ChooserActivity"; + private static final boolean DEBUG = true; + + public static final int SELECTION_TYPE_SERVICE = 1; + public static final int SELECTION_TYPE_APP = 2; + public static final int SELECTION_TYPE_STANDARD = 3; + public static final int SELECTION_TYPE_COPY = 4; + public static final int SELECTION_TYPE_NEARBY = 5; + public static final int SELECTION_TYPE_EDIT = 6; + /** * This shim is provided only for testing. In production, clients will only ever use a * {@link DefaultFrameworkStatsLogger}. @@ -70,15 +87,30 @@ public class ChooserActivityLogger { private final UiEventLogger mUiEventLogger; private final FrameworkStatsLogger mFrameworkStatsLogger; + private final MetricsLogger mMetricsLogger; public ChooserActivityLogger() { - this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger()); + this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); } @VisibleForTesting - ChooserActivityLogger(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger) { + ChooserActivityLogger( + UiEventLogger uiEventLogger, + FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger) { mUiEventLogger = uiEventLogger; mFrameworkStatsLogger = frameworkLogger; + mMetricsLogger = metricsLogger; + } + + /** Records metrics for the start time of the {@link ChooserActivity}. */ + public void logChooserActivityShown( + boolean isWorkProfile, String targetMimeType, long systemCost) { + mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) + .setSubtype( + isWorkProfile ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE) + .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, targetMimeType) + .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); } /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ @@ -97,15 +129,92 @@ public class ChooserActivityLogger { /* intentType = 9 */ typeFromIntentString(intent)); } - /** Logs a UiEventReported event for the system sharesheet when the user selects a target. */ - public void logShareTargetSelected(int targetType, String packageName, int positionPicked, - boolean isPinned) { + /** + * Logs a UiEventReported event for the system sharesheet when the user selects a target. + * TODO: document parameters and/or consider breaking up by targetType so we don't have to + * support an overly-generic signature. + */ + public void logShareTargetSelected( + int targetType, + String packageName, + int positionPicked, + int directTargetAlsoRanked, + int numCallerProvided, + @Nullable HashedStringCache.HashResult directTargetHashed, + boolean isPinned, + boolean successfullySelected, + long selectionCost) { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ packageName, /* instance_id = 3 */ getInstanceId().getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ isPinned); + + int category = getTargetSelectionCategory(targetType); + if (category != 0) { + LogMaker targetLogMaker = new LogMaker(category).setSubtype(positionPicked); + if (directTargetHashed != null) { + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, + directTargetHashed.saltGeneration); + targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, + directTargetAlsoRanked); + } + targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, numCallerProvided); + mMetricsLogger.write(targetLogMaker); + } + + if (successfullySelected) { + if (DEBUG) { + Log.d(TAG, "User Selection Time Cost is " + selectionCost); + Log.d(TAG, "position of selected app/service/caller is " + positionPicked); + } + MetricsLogger.histogram( + null, "user_selection_cost_for_smart_sharing", (int) selectionCost); + MetricsLogger.histogram(null, "app_position_for_smart_sharing", positionPicked); + } + } + + /** Log when direct share targets were received. */ + public void logDirectShareTargetReceived(int category, int latency) { + mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); + } + + /** + * Log when we display a preview UI of the specified {@code previewType} as part of our + * Sharesheet session. + */ + public void logActionShareWithPreview(int previewType) { + mMetricsLogger.write( + new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); + } + + /** Log when the user selects an action button with the specified {@code targetType}. */ + public void logActionSelected(int targetType) { + if (targetType == SELECTION_TYPE_COPY) { + LogMaker targetLogMaker = new LogMaker( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); + mMetricsLogger.write(targetLogMaker); + } + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ "", + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ -1, + /* is_pinned = 5 */ false); + } + + /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ + public void logContentPreviewWarning(Uri uri) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " + + "desired, consider using Intent#createChooser to launch the ChooserActivity, " + + "and set your Intent's clipData and flags in accordance with that method's " + + "documentation"); + } /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ @@ -231,17 +340,17 @@ public class ChooserActivityLogger { public static SharesheetTargetSelectedEvent fromTargetType(int targetType) { switch(targetType) { - case ChooserActivity.SELECTION_TYPE_SERVICE: + case SELECTION_TYPE_SERVICE: return SHARESHEET_SERVICE_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_APP: + case SELECTION_TYPE_APP: return SHARESHEET_APP_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_STANDARD: + case SELECTION_TYPE_STANDARD: return SHARESHEET_STANDARD_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_COPY: + case SELECTION_TYPE_COPY: return SHARESHEET_COPY_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_NEARBY: + case SELECTION_TYPE_NEARBY: return SHARESHEET_NEARBY_TARGET_SELECTED; - case ChooserActivity.SELECTION_TYPE_EDIT: + case SELECTION_TYPE_EDIT: return SHARESHEET_EDIT_TARGET_SELECTED; default: return INVALID; @@ -328,6 +437,20 @@ public class ChooserActivityLogger { } } + @VisibleForTesting + static int getTargetSelectionCategory(int targetType) { + switch (targetType) { + case SELECTION_TYPE_SERVICE: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; + case SELECTION_TYPE_APP: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; + case SELECTION_TYPE_STANDARD: + return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + default: + return 0; + } + } + private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { @Override public void write( diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index 702e725a..705a3228 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -30,14 +30,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import android.content.Intent; +import android.metrics.LogMaker; import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; import com.android.internal.logging.InstanceId; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; import com.android.internal.logging.UiEventLogger.UiEventEnum; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; import org.junit.After; @@ -52,18 +55,59 @@ import org.mockito.junit.MockitoJUnitRunner; public final class ChooserActivityLoggerTest { @Mock private UiEventLogger mUiEventLog; @Mock private FrameworkStatsLogger mFrameworkLog; + @Mock private MetricsLogger mMetricsLogger; private ChooserActivityLogger mChooserLogger; @Before public void setUp() { - mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); + mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); } @After public void tearDown() { verifyNoMoreInteractions(mUiEventLog); verifyNoMoreInteractions(mFrameworkLog); + verifyNoMoreInteractions(mMetricsLogger); + } + + @Test + public void testLogChooserActivityShown_personalProfile() { + final boolean isWorkProfile = false; + final String mimeType = "application/TestType"; + final long systemCost = 456; + + mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); + assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE); + assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); + assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) + .isEqualTo(systemCost); + } + + @Test + public void testLogChooserActivityShown_workProfile() { + final boolean isWorkProfile = true; + final String mimeType = "application/TestType"; + final long systemCost = 456; + + mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); + assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE); + assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); + assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) + .isEqualTo(systemCost); } @Test @@ -102,20 +146,87 @@ public final class ChooserActivityLoggerTest { @Test public void testLogShareTargetSelected() { - final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final int targetType = ChooserActivityLogger.SELECTION_TYPE_SERVICE; final String packageName = "com.test.foo"; final int positionPicked = 123; - final boolean pinned = true; - - mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); verify(mFrameworkLog).write( eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), + eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()), eq(packageName), /* instanceId=*/ gt(0), eq(positionPicked), - eq(pinned)); + eq(isPinned)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); + assertThat(event.getSubtype()).isEqualTo(positionPicked); + } + + @Test + public void testLogActionSelected() { + mChooserLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), + eq(""), + /* instanceId=*/ gt(0), + eq(-1), + eq(false)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET); + assertThat(event.getSubtype()).isEqualTo(1); + } + + @Test + public void testLogDirectShareTargetReceived() { + final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER; + final int latency = 123; + + mChooserLogger.logDirectShareTargetReceived(category, latency); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo(category); + assertThat(event.getSubtype()).isEqualTo(latency); + } + + @Test + public void testLogActionShareWithPreview() { + final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT; + + mChooserLogger.logActionShareWithPreview(previewType); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LogMaker.class); + verify(mMetricsLogger).write(eventCaptor.capture()); + LogMaker event = eventCaptor.getValue(); + assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW); + assertThat(event.getSubtype()).isEqualTo(previewType); } @Test @@ -194,15 +305,38 @@ public final class ChooserActivityLoggerTest { public void testDifferentLoggerInstancesUseDifferentInstanceIds() { ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); ChooserActivityLogger chooserLogger2 = - new ChooserActivityLogger(mUiEventLog, mFrameworkLog); + new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); - final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; final String packageName = "com.test.foo"; final int positionPicked = 123; - final boolean pinned = true; - - mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); - chooserLogger2.logShareTargetSelected(targetType, packageName, positionPicked, pinned); + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); + + chooserLogger2.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); verify(mFrameworkLog, times(2)).write( anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); @@ -220,12 +354,26 @@ public final class ChooserActivityLoggerTest { ArgumentCaptor idIntCaptor = ArgumentCaptor.forClass(Integer.class); ArgumentCaptor idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); - final int targetType = ChooserActivity.SELECTION_TYPE_COPY; + final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; final String packageName = "com.test.foo"; final int positionPicked = 123; - final boolean pinned = true; + final int directTargetAlsoRanked = -1; + final int callerTargetCount = 0; + final boolean isPinned = true; + final boolean isSuccessfullySelected = true; + final long selectionCost = 456; + + mChooserLogger.logShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + callerTargetCount, + /* directTargetHashed= */ null, + isPinned, + isSuccessfullySelected, + selectionCost); - mChooserLogger.logShareTargetSelected(targetType, packageName, positionPicked, pinned); verify(mFrameworkLog).write( anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); @@ -236,4 +384,23 @@ public final class ChooserActivityLoggerTest { assertThat(idIntCaptor.getValue()).isGreaterThan(0); assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); } + + @Test + public void testTargetSelectionCategories() { + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_SERVICE)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_APP)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET); + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_STANDARD)) + .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET); + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_COPY)).isEqualTo(0); + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_NEARBY)).isEqualTo(0); + assertThat(ChooserActivityLogger.getTargetSelectionCategory( + ChooserActivityLogger.SELECTION_TYPE_EDIT)).isEqualTo(0); + } } diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 5acdb42c..5df0d4a2 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -32,7 +32,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvi import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.MetricsLogger; import java.util.function.Consumer; import java.util.function.Function; @@ -66,7 +65,6 @@ public class ChooserActivityOverrideData { public Cursor resolverCursor; public boolean resolverForceException; public Bitmap previewThumbnail; - public MetricsLogger metricsLogger; public ChooserActivityLogger chooserActivityLogger; public int alternateProfileSetting; public Resources resources; @@ -89,7 +87,6 @@ public class ChooserActivityOverrideData { resolverForceException = false; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); - metricsLogger = mock(MetricsLogger.class); chooserActivityLogger = mock(ChooserActivityLogger.class); alternateProfileSetting = 0; resources = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 04e727ba..9f1dab77 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -44,7 +44,6 @@ import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; @@ -222,11 +221,6 @@ public class ChooserWrapperActivity return sOverrides.isImageType; } - @Override - protected MetricsLogger getMetricsLogger() { - return sOverrides.metricsLogger; - } - @Override public ChooserActivityLogger getChooserActivityLogger() { return sOverrides.chooserActivityLogger; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index ff166fb7..af2557ef 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -43,15 +43,13 @@ import static junit.framework.Assert.assertNull; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -77,12 +75,12 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.drawable.Icon; -import android.metrics.LogMaker; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; +import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; @@ -99,7 +97,6 @@ import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import org.hamcrest.Description; @@ -786,26 +783,15 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); - verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); - - // The last captured event is the selection of the target. - boolean containsTargetEvent = logMakerCaptor.getAllValues() - .stream() - .anyMatch(item -> - item.getCategory() - == MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET); - assertTrue( - "ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET is expected", containsTargetEvent); - assertThat(logMakerCaptor.getValue().getSubtype(), is(1)); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY)); } @Test @@ -979,25 +965,12 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS), - is(notNullValue())); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE), - is(TEST_MIME_TYPE)); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getSubtype(), - is(MetricsEvent.PARENT_PROFILE)); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + waitForIdle(); + + verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong()); } @Test @@ -1006,49 +979,32 @@ public class UnbundledChooserActivityTest { sendIntent.setType(TEST_MIME_TYPE); ChooserActivityOverrideData.getInstance().alternateProfileSetting = MetricsEvent.MANAGED_PROFILE; - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS), - is(notNullValue())); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE), - is(TEST_MIME_TYPE)); - assertThat(logMakerCaptor - .getAllValues().get(0) - .getSubtype(), - is(MetricsEvent.MANAGED_PROFILE)); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + waitForIdle(); + + verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong()); } @Test public void testEmptyPreviewLogging() { Intent sendIntent = createSendTextIntentWithPreview(null, null); - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "empty preview logger test")); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity( + Intent.createChooser(sendIntent, "empty preview logger test")); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); waitForIdle(); - verify(mockLogger, Mockito.times(1)).write(logMakerCaptor.capture()); - // First invocation is from onCreate - assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); + verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong()); } @Test public void testTitlePreviewLogging() { Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - List resolvedComponentInfos = createResolvedComponentsForTest(2); when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( @@ -1057,14 +1013,13 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); + // Second invocation is from onCreate - verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(), - is(CONTENT_PREVIEW_TEXT)); - assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), - is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW)); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT)); } @Test @@ -1092,16 +1047,11 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture()); - // First invocation is from onCreate - assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(), - is(CONTENT_PREVIEW_IMAGE)); - assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), - is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW)); + ChooserActivityLogger logger = activity.getChooserActivityLogger(); + Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE)); } @Test @@ -1302,10 +1252,6 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Set up resources - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = createShortcutLoaderFactory(); @@ -1361,25 +1307,22 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 4 invocations - // 1. ChooserActivity.logActionShareWithPreview() - // 2. ChooserActivity.onCreate() - // 3. ChooserActivity.logDirectShareTargetReceived() - // 4. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); - LogMaker selectionLog = logMakerCaptor.getAllValues().get(3); - assertThat( - selectionLog.getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - String hashedName = (String) selectionLog.getTaggedData( - MetricsEvent.FIELD_HASHED_TARGET_NAME); + ArgumentCaptor hashCaptor = + ArgumentCaptor.forClass(HashedStringCache.HashResult.class); + verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + /* packageName= */ any(), + /* positionPicked= */ anyInt(), + /* directTargetAlsoRanked= */ eq(-1), + /* numCallerProvided= */ anyInt(), + /* directTargetHashed= */ hashCaptor.capture(), + /* isPinned= */ anyBoolean(), + /* successfullySelected= */ anyBoolean(), + /* selectionCost= */ anyLong()); + String hashedName = hashCaptor.getValue().hashedString; assertThat( "Hash is not predictable but must be obfuscated", hashedName, is(not(name))); - assertThat( - "The packages shouldn't match for app target and direct target", - selectionLog.getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), - is(-1)); } // This test is too long and too slow and should not be taken as an example for future tests. @@ -1399,10 +1342,6 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Set up resources - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); - // create test shortcut loader factory, remember loaders and their callbacks SparseArray>> shortcutLoaders = createShortcutLoaderFactory(); @@ -1460,16 +1399,16 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 4 invocations - // 1. ChooserActivity.logActionShareWithPreview() - // 2. ChooserActivity.onCreate() - // 3. ChooserActivity.logDirectShareTargetReceived() - // 4. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(4)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(3).getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - assertThat("The packages should match for app target and direct target", logMakerCaptor - .getAllValues().get(3).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + /* packageName= */ any(), + /* positionPicked= */ anyInt(), + /* directTargetAlsoRanked= */ eq(0), + /* numCallerProvided= */ anyInt(), + /* directTargetHashed= */ any(), + /* isPinned= */ anyBoolean(), + /* successfullySelected= */ anyBoolean(), + /* selectionCost= */ anyLong()); } @Test @@ -1787,9 +1726,6 @@ public class UnbundledChooserActivityTest { Mockito.isA(List.class))) .thenReturn(resolvedComponentInfos); - // Set up resources - MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; - ArgumentCaptor logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); // Create direct share target List serviceTargets = createDirectShareTargets(1, resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); @@ -1830,15 +1766,18 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - // Currently we're seeing 3 invocations - // 1. ChooserActivity.onCreate() - // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() - // 3. ChooserActivity.startSelected -- which is the one we're after - verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); - assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), - is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); - assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor - .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + ChooserActivityLogger logger = wrapper.getChooserActivityLogger(); + verify(logger, times(1)).logShareTargetSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + /* packageName= */ any(), + /* positionPicked= */ anyInt(), + // The packages sholdn't match for app target and direct target: + /* directTargetAlsoRanked= */ eq(-1), + /* numCallerProvided= */ anyInt(), + /* directTargetHashed= */ any(), + /* isPinned= */ anyBoolean(), + /* successfullySelected= */ anyBoolean(), + /* selectionCost= */ anyLong()); } @Test @@ -2179,9 +2118,16 @@ public class UnbundledChooserActivityTest { ChooserActivityLogger logger = activity.getChooserActivityLogger(); ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(Integer.class); - Mockito.verify(logger, times(1)) - .logShareTargetSelected(typeCaptor.capture(), any(), anyInt(), anyBoolean()); - assertThat(typeCaptor.getValue(), is(ChooserActivity.SELECTION_TYPE_SERVICE)); + verify(logger, times(1)).logShareTargetSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + /* packageName= */ any(), + /* positionPicked= */ anyInt(), + /* directTargetAlsoRanked= */ anyInt(), + /* numCallerProvided= */ anyInt(), + /* directTargetHashed= */ any(), + /* isPinned= */ anyBoolean(), + /* successfullySelected= */ anyBoolean(), + /* selectionCost= */ anyLong()); } @Test @Ignore -- cgit v1.2.3-59-g8ed1b From 3f2db6f582dd9283ad8a582b7ed95c7ce9863399 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 8 Dec 2022 17:50:57 -0500 Subject: Introduce GenericMultiProfilePagerAdapter This is an intermediate step in simplifying the full class hierarchy around the MultiProfilePagerAdapters in support of our general interest in clarifying/decoupling the chooser/resolver relationship, and especially in support of our specific goals in being able to build these UIs outside of the chooser/resolver activities (for the embedded sharesheet project). The new class takes over (almost) all of the responsibilities of the two legacy adapter implementations, leaving those components (almost) trivially implemented in terms of this new unified implementation. The remaining responsibilities are extracted to delegate interfaces with corresponding implementations in the legacy components that get injected in the initialization of the new "Generic" base class. (This is just one possible abstraction that I've chosen for now because it trivially preserves the behavior while setting up a structure that's easier to manipulate in our refactoring. In the future, we could consider alternatives -- maybe using simpler "setter" APIs, or adjusting our design to pull these responsibilities out of the adapter altogether.) Following this CL, we should merge this new component up into the base `AbstractMultiProfilePagerAdapter`, then close that API to further extension. We may also remove the use of inheritance as the model for pre-specifying "chooser" vs. "resolver" configurations; we'd only have a single (generic) class, with any behavioral variation configured at initialization time. Notably, the use of generics in the new class replaces a lot of duplication where the chooser implementation just needed to provide overrides to specify the narrower chooser-specific types. I expect that the legacy code has several other places where the chooser-specific version of a class exists largely for these "glue code" responsibilities of switching the entire design over to using the chooser "family" of concrete classes. There's lots of problems with that approach. It's unnecessarily flexible/complex (our application doesn't require runtime polymorphism to select between chooser and resolver implementations); it creates a lot of complex/error-prone situations where the safety of our downcasts relies on our knowledge of how the system is configured at runtime (and our trust in the participating components to honor their [undocumented] contracts); it creates unnecessary coupling and duplication as the configuration has to be pushed through to indirect dependents; and in practice we often have chooser extend resolver instead of pulling up a base class, so the chooser API surfaces grow to accommodate every customization we ever support for the resolver case (often without any particular documentation or contracts for subclass implementors). As in this CL, generics are often the simplest way to preserve the legacy APIs while reducing duplication, and they provide all the flexibility we need since our "app configurations" are static at compile-time. When more runtime flexibility is required, GoF "creational" and "behavioral" patterns specify well-defined alternatives that we should target strictly in our refactorings to prevent the kinds of ad-hoc scope creep that results in our legacy components' current incoherent mix of responsibilities. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I2cde7ee2e0cbed43499bfe457b6b99f239bdfb51 --- .../AbstractMultiProfilePagerAdapter.java | 8 +- .../android/intentresolver/ChooserActivity.java | 2 +- .../ChooserMultiProfilePagerAdapter.java | 225 +++++++++------------ .../GenericMultiProfilePagerAdapter.java | 225 +++++++++++++++++++++ .../ResolverMultiProfilePagerAdapter.java | 196 ++++++------------ 5 files changed, 391 insertions(+), 265 deletions(-) create mode 100644 java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 8b0b10b0..17dbb8f2 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -16,8 +16,8 @@ package com.android.intentresolver; import android.annotation.IntDef; -import android.annotation.Nullable; import android.annotation.NonNull; +import android.annotation.Nullable; import android.annotation.UserIdInt; import android.app.AppGlobals; import android.content.ContentResolver; @@ -173,6 +173,10 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ abstract ProfileDescriptor getItem(int pageIndex); + protected ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + /** * Returns the number of {@link ProfileDescriptor} objects. *

      For a normal consumer device with only one user returns 1. @@ -432,7 +436,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { && isQuietModeEnabled(mWorkProfileUserHandle)); } - protected class ProfileDescriptor { + protected static class ProfileDescriptor { final ViewGroup rootView; private final ViewGroup mEmptyStateView; ProfileDescriptor(ViewGroup rootView) { diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 4682ec50..295d5b70 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1923,7 +1923,7 @@ public class ChooserActivity extends ResolverActivity implements private ViewGroup getActiveEmptyStateView() { int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getItem(currentPage).getEmptyStateView(); + return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); } @Override // ResolverListCommunicator diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 93daa299..39d1fab0 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -16,11 +16,9 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; import androidx.recyclerview.widget.GridLayoutManager; @@ -30,16 +28,21 @@ import androidx.viewpager.widget.PagerAdapter; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.internal.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ @VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { +public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; - private final ChooserProfileDescriptor[] mItems; - private int mBottomOffset; - private int mMaxTargetsPerRow; + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; ChooserMultiProfilePagerAdapter( Context context, @@ -48,12 +51,15 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd QuietModeManager quietModeManager, UserHandle workProfileUserHandle, int maxTargetsPerRow) { - super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, - workProfileUserHandle); - mItems = new ChooserProfileDescriptor[] { - createProfileDescriptor(adapter) - }; - mMaxTargetsPerRow = maxTargetsPerRow; + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(adapter), + emptyStateProvider, + quietModeManager, + /* defaultProfile= */ 0, + workProfileUserHandle, + new BottomPaddingOverrideSupplier(context)); } ChooserMultiProfilePagerAdapter( @@ -65,143 +71,104 @@ public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAd @Profile int defaultProfile, UserHandle workProfileUserHandle, int maxTargetsPerRow) { - super(context, /* currentPage */ defaultProfile, emptyStateProvider, - quietModeManager, workProfileUserHandle); - mItems = new ChooserProfileDescriptor[] { - createProfileDescriptor(personalAdapter), - createProfileDescriptor(workAdapter) - }; - mMaxTargetsPerRow = maxTargetsPerRow; - } - - private ChooserProfileDescriptor createProfileDescriptor(ChooserGridAdapter adapter) { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - final ViewGroup rootView = - (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); - ChooserProfileDescriptor profileDescriptor = - new ChooserProfileDescriptor(rootView, adapter); - profileDescriptor.recyclerView.setAccessibilityDelegateCompat( - new ChooserRecyclerViewAccessibilityDelegate(profileDescriptor.recyclerView)); - return profileDescriptor; + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + new BottomPaddingOverrideSupplier(context)); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList gridAdapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + context, + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + gridAdapters, + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + () -> makeProfileView(context), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; } public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - RecyclerView getListViewForIndex(int index) { - return getItem(index).recyclerView; + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); } - @Override - ChooserProfileDescriptor getItem(int pageIndex) { - return mItems[pageIndex]; + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); } - @Override - int getItemCount() { - return mItems.length; + private static ViewGroup makeProfileView(Context context) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = (ViewGroup) inflater.inflate( + R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; } - @Override - @VisibleForTesting - public ChooserGridAdapter getAdapterForIndex(int pageIndex) { - return mItems[pageIndex].chooserGridAdapter; - } + private static class BottomPaddingOverrideSupplier implements Supplier> { + private final Context mContext; + private int mBottomOffset; - @Override - @Nullable - ChooserListAdapter getListAdapterForUserHandle(UserHandle userHandle) { - if (getActiveListAdapter().getUserHandle().equals(userHandle)) { - return getActiveListAdapter(); - } else if (getInactiveListAdapter() != null - && getInactiveListAdapter().getUserHandle().equals(userHandle)) { - return getInactiveListAdapter(); + BottomPaddingOverrideSupplier(Context context) { + mContext = context; } - return null; - } - - @Override - void setupListAdapter(int pageIndex) { - final RecyclerView recyclerView = getItem(pageIndex).recyclerView; - ChooserGridAdapter chooserGridAdapter = getItem(pageIndex).chooserGridAdapter; - GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); - glm.setSpanCount(mMaxTargetsPerRow); - glm.setSpanSizeLookup( - new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return chooserGridAdapter.shouldCellSpan(position) - ? SINGLE_CELL_SPAN_SIZE - : glm.getSpanCount(); - } - }); - } - @Override - @VisibleForTesting - public ChooserListAdapter getActiveListAdapter() { - return getAdapterForIndex(getCurrentPage()).getListAdapter(); - } - - @Override - @VisibleForTesting - public ChooserListAdapter getInactiveListAdapter() { - if (getCount() == 1) { - return null; + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; } - return getAdapterForIndex(1 - getCurrentPage()).getListAdapter(); - } - - @Override - public ResolverListAdapter getPersonalListAdapter() { - return getAdapterForIndex(PROFILE_PERSONAL).getListAdapter(); - } - - @Override - @Nullable - public ResolverListAdapter getWorkListAdapter() { - return getAdapterForIndex(PROFILE_WORK).getListAdapter(); - } - @Override - ChooserGridAdapter getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); + public Optional get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); + } } - @Override - RecyclerView getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } + private static class ChooserProfileAdapterBinder implements + AdapterBinder { + private int mMaxTargetsPerRow; - @Override - @Nullable - RecyclerView getInactiveAdapterView() { - if (getCount() == 1) { - return null; + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; } - return getListViewForIndex(1 - getCurrentPage()); - } - void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - @Override - protected void setupContainerPadding(View container) { - int initialBottomPadding = getContext().getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), - container.getPaddingRight(), initialBottomPadding + mBottomOffset); - } + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } - class ChooserProfileDescriptor extends ProfileDescriptor { - private ChooserGridAdapter chooserGridAdapter; - private RecyclerView recyclerView; - ChooserProfileDescriptor(ViewGroup rootView, ChooserGridAdapter adapter) { - super(rootView); - chooserGridAdapter = adapter; - recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); } } } diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java new file mode 100644 index 00000000..9bbdf7c7 --- /dev/null +++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.Nullable; +import android.content.Context; +import android.os.UserHandle; +import android.view.View; +import android.view.ViewGroup; + +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in + * existing implementations; most overrides were only to vary type signatures (which are better + * represented via generic types), and a few minor behavioral customizations are now implemented + * through small injectable delegate classes. + * TODO: now that the existing implementations are shown to be expressible in terms of this new + * generic type, merge up into the base class and simplify the public APIs. + * TODO: attempt to further restrict visibility in the methods we expose. + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * + * @param the type of the widget that represents the contents of a page in this adapter + * @param the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. + * + * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the + * type constraint can probably be dropped once the API is merged upwards and cleaned. + */ +class GenericMultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter { + + /** Delegate to set up a given adapter and page view to be used together. */ + public interface AdapterBinder { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } + + private final Function mListAdapterExtractor; + private final AdapterBinder mAdapterBinder; + private final Supplier mPageViewInflater; + private final Supplier> mContainerBottomPaddingOverrideSupplier; + + private final ImmutableList> mItems; + + GenericMultiProfilePagerAdapter( + Context context, + Function listAdapterExtractor, + AdapterBinder adapterBinder, + ImmutableList adapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + Supplier pageViewInflater, + Supplier> containerBottomPaddingOverrideSupplier) { + super( + context, + /* currentPage= */ defaultProfile, + emptyStateProvider, + quietModeManager, + workProfileUserHandle); + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + + ImmutableList.Builder> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter)); + } + mItems = items.build(); + } + + private GenericProfileDescriptor + createProfileDescriptor(SinglePageAdapterT adapter) { + return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter); + } + + @Override + protected GenericProfileDescriptor getItem(int pageIndex) { + return mItems.get(pageIndex); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + + public PageViewT getListViewForIndex(int index) { + return getItem(index).mView; + } + + @Override + @VisibleForTesting + public SinglePageAdapterT getAdapterForIndex(int index) { + return getItem(index).mAdapter; + } + + @Override + protected void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); + } + + @Override + public ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + return super.instantiateItem(container, position); + } + + @Override + @Nullable + protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + if (getActiveListAdapter().getUserHandle().equals(userHandle)) { + return getActiveListAdapter(); + } + if ((getInactiveListAdapter() != null) && getInactiveListAdapter().getUserHandle().equals( + userHandle)) { + return getInactiveListAdapter(); + } + return null; + } + + @Override + @VisibleForTesting + public ListAdapterT getActiveListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); + } + + @Override + @VisibleForTesting + public ListAdapterT getInactiveListAdapter() { + if (getCount() < 2) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); + } + + @Override + public ListAdapterT getPersonalListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); + } + + @Override + public ListAdapterT getWorkListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + } + + @Override + protected SinglePageAdapterT getCurrentRootAdapter() { + return getAdapterForIndex(getCurrentPage()); + } + + @Override + protected PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + @Override + protected PageViewT getInactiveAdapterView() { + if (getCount() < 2) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } + + @Override + protected void setupContainerPadding(View container) { + Optional bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + container.setPadding( + container.getPaddingLeft(), + container.getPaddingTop(), + container.getPaddingRight(), + paddingBottom)); + } + + // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" + // should be the owner of all per-profile data (especially now that the API is generic)? + private static class GenericProfileDescriptor extends + ProfileDescriptor { + private final SinglePageAdapterT mAdapter; + private final PageViewT mView; + + GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { + super(rootView); + mAdapter = adapter; + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 8cf65529..65de9409 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -16,11 +16,9 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; import android.widget.ListView; @@ -28,25 +26,33 @@ import androidx.viewpager.widget.PagerAdapter; import com.android.internal.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. */ @VisibleForTesting -public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { - - private final ResolverProfileDescriptor[] mItems; - private boolean mUseLayoutWithDefault; +public class ResolverMultiProfilePagerAdapter extends + GenericMultiProfilePagerAdapter { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter(Context context, + ResolverMultiProfilePagerAdapter( + Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, QuietModeManager quietModeManager, UserHandle workProfileUserHandle) { - super(context, /* currentPage */ 0, emptyStateProvider, quietModeManager, - workProfileUserHandle); - mItems = new ResolverProfileDescriptor[] { - createProfileDescriptor(adapter) - }; + this( + context, + ImmutableList.of(adapter), + emptyStateProvider, + quietModeManager, + /* defaultProfile= */ 0, + workProfileUserHandle, + new BottomPaddingOverrideSupplier()); } ResolverMultiProfilePagerAdapter(Context context, @@ -56,129 +62,53 @@ public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerA QuietModeManager quietModeManager, @Profile int defaultProfile, UserHandle workProfileUserHandle) { - super(context, /* currentPage */ defaultProfile, emptyStateProvider, quietModeManager, - workProfileUserHandle); - mItems = new ResolverProfileDescriptor[] { - createProfileDescriptor(personalAdapter), - createProfileDescriptor(workAdapter) - }; - } - - private ResolverProfileDescriptor createProfileDescriptor( - ResolverListAdapter adapter) { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - final ViewGroup rootView = - (ViewGroup) inflater.inflate(R.layout.resolver_list_per_profile, null, false); - return new ResolverProfileDescriptor(rootView, adapter); - } - - ListView getListViewForIndex(int index) { - return getItem(index).listView; - } - - @Override - ResolverProfileDescriptor getItem(int pageIndex) { - return mItems[pageIndex]; - } - - @Override - int getItemCount() { - return mItems.length; - } - - @Override - void setupListAdapter(int pageIndex) { - final ListView listView = getItem(pageIndex).listView; - listView.setAdapter(getItem(pageIndex).resolverListAdapter); - } - - @Override - @VisibleForTesting - public ResolverListAdapter getAdapterForIndex(int pageIndex) { - return mItems[pageIndex].resolverListAdapter; - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - return super.instantiateItem(container, position); - } - - @Override - @Nullable - ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle) { - if (getActiveListAdapter().getUserHandle().equals(userHandle)) { - return getActiveListAdapter(); - } else if (getInactiveListAdapter() != null - && getInactiveListAdapter().getUserHandle().equals(userHandle)) { - return getInactiveListAdapter(); - } - return null; - } - - @Override - @VisibleForTesting - public ResolverListAdapter getActiveListAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - @Override - @VisibleForTesting - public ResolverListAdapter getInactiveListAdapter() { - if (getCount() == 1) { - return null; - } - return getAdapterForIndex(1 - getCurrentPage()); - } - - @Override - public ResolverListAdapter getPersonalListAdapter() { - return getAdapterForIndex(PROFILE_PERSONAL); - } - - @Override - @Nullable - public ResolverListAdapter getWorkListAdapter() { - return getAdapterForIndex(PROFILE_WORK); - } - - @Override - ResolverListAdapter getCurrentRootAdapter() { - return getActiveListAdapter(); - } - - @Override - ListView getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - @Nullable - ViewGroup getInactiveAdapterView() { - if (getCount() == 1) { - return null; + this( + context, + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList listAdapters, + EmptyStateProvider emptyStateProvider, + QuietModeManager quietModeManager, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + context, + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + listAdapters, + emptyStateProvider, + quietModeManager, + defaultProfile, + workProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; } - return getListViewForIndex(1 - getCurrentPage()); - } - - void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @Override - protected void setupContainerPadding(View container) { - int bottom = mUseLayoutWithDefault ? container.getPaddingBottom() : 0; - container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), - container.getPaddingRight(), bottom); - } - class ResolverProfileDescriptor extends ProfileDescriptor { - private ResolverListAdapter resolverListAdapter; - final ListView listView; - ResolverProfileDescriptor(ViewGroup rootView, ResolverListAdapter adapter) { - super(rootView); - resolverListAdapter = adapter; - listView = rootView.findViewById(com.android.internal.R.id.resolver_list); + @Override + public Optional get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); } } } -- cgit v1.2.3-59-g8ed1b From 1d4f1782d24d8d881713de4e3ad9ca9156913455 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 14 Dec 2022 20:15:03 -0800 Subject: Create an action row view Create a new widget, ActionRow, that encapsulates action row UI under an generalized interface. A preparation step for having an alternative action row UI implementation protected by a feature flag. Bug: 262278109 Test: manual test for all preview types and actions in both cases, when a preview is a part of the recycle view and when it's detached, for UI and functionality (a11y included) Test: atest IntentResolverUnitTests Change-Id: I3e78a85386f6ea49feebeef8b15e2b6d2d6e9234 --- java/res/layout/chooser_action_row.xml | 7 +- .../android/intentresolver/ChooserActivity.java | 95 +++++++--------- .../intentresolver/ChooserContentPreviewUi.java | 123 ++++++++++++--------- .../com/android/intentresolver/widget/ActionRow.kt | 93 ++++++++++++++++ 4 files changed, 202 insertions(+), 116 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ActionRow.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index ea756112..fd47155c 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -14,13 +14,10 @@ ~ limitations under the License --> - - - + android:gravity="center" /> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 6d5304d9..6cf1aef4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -77,7 +77,6 @@ import android.util.Log; import android.util.Size; import android.util.Slog; import android.util.SparseArray; -import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; @@ -86,7 +85,6 @@ import android.view.WindowInsets; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.LinearInterpolator; -import android.widget.Button; import android.widget.TextView; import androidx.annotation.MainThread; @@ -97,7 +95,6 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -108,6 +105,7 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -629,7 +627,7 @@ public class ChooserActivity extends ResolverActivity implements updateProfileViewButton(); } - private void onCopyButtonClicked(View v) { + private void onCopyButtonClicked() { Intent targetIntent = getTargetIntent(); if (targetIntent == null) { finish(); @@ -752,21 +750,23 @@ public class ChooserActivity extends ResolverActivity implements int previewType = ChooserContentPreviewUi.findPreferredContentPreview( targetIntent, getContentResolver(), this::isImageType); - ChooserContentPreviewUi.ActionButtonFactory buttonFactory = - new ChooserContentPreviewUi.ActionButtonFactory() { + ChooserContentPreviewUi.ActionFactory actionFactory = + new ChooserContentPreviewUi.ActionFactory() { @Override - public Button createCopyButton() { - return ChooserActivity.this.createCopyButton(); + public ActionRow.Action createCopyButton() { + return ChooserActivity.this.createCopyAction(); } + @Nullable @Override - public Button createEditButton() { - return ChooserActivity.this.createEditButton(targetIntent); + public ActionRow.Action createEditButton() { + return ChooserActivity.this.createEditAction(targetIntent); } + @Nullable @Override - public Button createNearbyButton() { - return ChooserActivity.this.createNearbyButton(targetIntent); + public ActionRow.Action createNearbyButton() { + return ChooserActivity.this.createNearbyAction(targetIntent); } }; @@ -775,7 +775,7 @@ public class ChooserActivity extends ResolverActivity implements targetIntent, getResources(), getLayoutInflater(), - buttonFactory, + actionFactory, parent, previewCoordinator, getContentResolver(), @@ -902,54 +902,46 @@ public class ChooserActivity extends ResolverActivity implements return dri; } - private Button createActionButton(Drawable icon, CharSequence title, View.OnClickListener r) { - Button b = (Button) LayoutInflater.from(this).inflate(R.layout.chooser_action_button, null); - if (icon != null) { - final int size = getResources() - .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size); - icon.setBounds(0, 0, size, size); - b.setCompoundDrawablesRelative(icon, null, null, null); - } - b.setText(title); - b.setOnClickListener(r); - return b; - } - - private Button createCopyButton() { - final Button b = createActionButton( + private ActionRow.Action createCopyAction() { + return new ActionRow.Action( + com.android.internal.R.id.chooser_copy_button, + getString(com.android.internal.R.string.copy), getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), - getString(com.android.internal.R.string.copy), this::onCopyButtonClicked); - b.setId(com.android.internal.R.id.chooser_copy_button); - return b; + this::onCopyButtonClicked); } - private @Nullable Button createNearbyButton(Intent originalIntent) { + @Nullable + private ActionRow.Action createNearbyAction(Intent originalIntent) { final TargetInfo ti = getNearbySharingTarget(originalIntent); - if (ti == null) return null; + if (ti == null) { + return null; + } - final Button b = createActionButton( - ti.getDisplayIconHolder().getDisplayIcon(), + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, ti.getDisplayLabel(), - (View unused) -> { + ti.getDisplayIconHolder().getDisplayIcon(), + () -> { getChooserActivityLogger().logActionSelected( ChooserActivityLogger.SELECTION_TYPE_NEARBY); // Action bar is user-independent, always start as primary safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); finish(); - } - ); - b.setId(com.android.internal.R.id.chooser_nearby_button); - return b; + }); } - private @Nullable Button createEditButton(Intent originalIntent) { + @Nullable + private ActionRow.Action createEditAction(Intent originalIntent) { final TargetInfo ti = getEditSharingTarget(originalIntent); - if (ti == null) return null; + if (ti == null) { + return null; + } - final Button b = createActionButton( - ti.getDisplayIconHolder().getDisplayIcon(), + return new ActionRow.Action( + com.android.internal.R.id.chooser_edit_button, ti.getDisplayLabel(), - (View unused) -> { + ti.getDisplayIconHolder().getDisplayIcon(), + () -> { // Log share completion via edit getChooserActivityLogger().logActionSelected( ChooserActivityLogger.SELECTION_TYPE_EDIT); @@ -967,8 +959,6 @@ public class ChooserActivity extends ResolverActivity implements } } ); - b.setId(com.android.internal.R.id.chooser_edit_button); - return b; } @Nullable @@ -977,17 +967,6 @@ public class ChooserActivity extends ResolverActivity implements return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null; } - private void addActionButton(ViewGroup parent, Button b) { - if (b == null) return; - final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ); - final int gap = getResources().getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2; - lp.setMarginsRelative(gap, 0, gap, 0); - parent.addView(b, lp); - } - /** * Wrapping the ContentResolver call to expose for easier mocking, * and to avoid mocking Android core classes. diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 22ff55db..f9f4ee98 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -34,11 +34,12 @@ import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; + +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -88,15 +89,17 @@ public final class ChooserContentPreviewUi { * they're determined to be appropriate for the particular preview we display. * TODO: clarify why action buttons are part of preview logic. */ - public interface ActionButtonFactory { - /** Create a button that copies the share content to the clipboard. */ - Button createCopyButton(); + public interface ActionFactory { + /** Create an action that copies the share content to the clipboard. */ + ActionRow.Action createCopyButton(); - /** Create a button that opens the share content in a system-default editor. */ - Button createEditButton(); + /** Create an action that opens the share content in a system-default editor. */ + @Nullable + ActionRow.Action createEditButton(); - /** Create a "Share to Nearby" button. */ - Button createNearbyButton(); + /** Create an "Share to Nearby" action. */ + @Nullable + ActionRow.Action createNearbyButton(); } /** @@ -173,7 +176,7 @@ public final class ChooserContentPreviewUi { Intent targetIntent, Resources resources, LayoutInflater layoutInflater, - ActionButtonFactory buttonFactory, + ActionFactory actionFactory, ViewGroup parent, ContentPreviewCoordinator previewCoord, ContentResolver contentResolver, @@ -184,18 +187,16 @@ public final class ChooserContentPreviewUi { case CONTENT_PREVIEW_TEXT: layout = displayTextContentPreview( targetIntent, - resources, layoutInflater, - buttonFactory, + createTextPreviewActions(actionFactory), parent, previewCoord); break; case CONTENT_PREVIEW_IMAGE: layout = displayImageContentPreview( targetIntent, - resources, layoutInflater, - buttonFactory, + createImagePreviewActions(actionFactory), parent, previewCoord, contentResolver, @@ -206,7 +207,7 @@ public final class ChooserContentPreviewUi { targetIntent, resources, layoutInflater, - buttonFactory, + createFilePreviewActions(actionFactory), parent, previewCoord, contentResolver); @@ -235,20 +236,18 @@ public final class ChooserContentPreviewUi { private static ViewGroup displayTextContentPreview( Intent targetIntent, - Resources resources, LayoutInflater layoutInflater, - ActionButtonFactory buttonFactory, + List actions, ViewGroup parent, ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById( - com.android.internal.R.id.chooser_action_row); - final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); - addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); - addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + if (actionRow != null) { + actionRow.setActions(actions); + } CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (sharingText == null) { @@ -296,11 +295,20 @@ public final class ChooserContentPreviewUi { return contentPreviewLayout; } + private static List createTextPreviewActions(ActionFactory actionFactory) { + ArrayList actions = new ArrayList<>(2); + actions.add(actionFactory.createCopyButton()); + ActionRow.Action nearbyAction = actionFactory.createNearbyButton(); + if (nearbyAction != null) { + actions.add(nearbyAction); + } + return actions; + } + private static ViewGroup displayImageContentPreview( Intent targetIntent, - Resources resources, LayoutInflater layoutInflater, - ActionButtonFactory buttonFactory, + List actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, ContentResolver contentResolver, @@ -310,13 +318,11 @@ public final class ChooserContentPreviewUi { ViewGroup imagePreview = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_image_area); - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById( - com.android.internal.R.id.chooser_action_row); - final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); - //TODO: addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); - addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); - addActionButton(actionRow, buttonFactory.createEditButton(), iconMargin); + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + if (actionRow != null) { + actionRow.setActions(actions); + } String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -375,24 +381,37 @@ public final class ChooserContentPreviewUi { return contentPreviewLayout; } + private static List createImagePreviewActions( + ActionFactory buttonFactory) { + ArrayList actions = new ArrayList<>(2); + //TODO: add copy action; + ActionRow.Action action = buttonFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + action = buttonFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + private static ViewGroup displayFileContentPreview( Intent targetIntent, Resources resources, LayoutInflater layoutInflater, - ActionButtonFactory buttonFactory, + List actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, ContentResolver contentResolver) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - final ViewGroup actionRow = - (ViewGroup) contentPreviewLayout.findViewById( - com.android.internal.R.id.chooser_action_row); - final int iconMargin = resources.getDimensionPixelSize(R.dimen.resolver_icon_margin); - //TODO(b/120417119): - // addActionButton(actionRow, buttonFactory.createCopyButton(), iconMargin); - addActionButton(actionRow, buttonFactory.createNearbyButton(), iconMargin); + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + if (actionRow != null) { + actionRow.setActions(actions); + } String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -438,6 +457,17 @@ public final class ChooserContentPreviewUi { return contentPreviewLayout; } + private static List createFilePreviewActions(ActionFactory actionFactory) { + List actions = new ArrayList<>(1); + //TODO(b/120417119): + // add action buttonFactory.createCopyButton() + ActionRow.Action action = actionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + private static void logContentPreviewWarning(Uri uri) { // The ContentResolver already logs the exception. Log something more informative. Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " @@ -475,19 +505,6 @@ public final class ChooserContentPreviewUi { } } - private static void addActionButton(ViewGroup parent, Button b, int iconMargin) { - if (b == null) { - return; - } - final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ); - final int gap = iconMargin / 2; - lp.setMarginsRelative(gap, 0, gap, 0); - parent.addView(b, lp); - } - private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { String fileName = null; boolean hasThumbnail = false; diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt new file mode 100644 index 00000000..1be48f34 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ActionRow.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2022 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.intentresolver.widget + +import android.annotation.LayoutRes +import android.content.Context +import android.content.res.Resources.ID_NULL +import android.graphics.drawable.Drawable +import android.os.Parcelable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.LinearLayout +import com.android.intentresolver.R + +// TODO: extract an interface out of the class, use it in layout hierarchy an have a layout inflater +// to instantiate the right view based on a flag value. +class ActionRow : LinearLayout { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) { + orientation = HORIZONTAL + } + + @LayoutRes + private val itemLayout = R.layout.chooser_action_button + private val itemMargin = + context.resources.getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2 + private var actions: List = emptyList() + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + setActions(actions) + } + + fun setActions(actions: List) { + removeAllViews() + this.actions = ArrayList(actions) + for (action in actions) { + addAction(action) + } + } + + private fun addAction(action: Action) { + val b = LayoutInflater.from(context).inflate(itemLayout, null) as Button + if (action.icon != null) { + val size = resources + .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size) + action.icon.setBounds(0, 0, size, size) + b.setCompoundDrawablesRelative(action.icon, null, null, null) + } + b.text = action.label ?: "" + b.setOnClickListener { + action.onClicked.run() + } + b.id = action.id + addView(b) + } + + override fun generateDefaultLayoutParams(): LayoutParams = + super.generateDefaultLayoutParams().apply { + setMarginsRelative(itemMargin, 0, itemMargin, 0) + } + + class Action @JvmOverloads constructor( + // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we + // get rid of them + val id: Int = ID_NULL, + val label: CharSequence?, + val icon: Drawable?, + val onClicked: Runnable, + ) +} -- cgit v1.2.3-59-g8ed1b From 6e627be666fe43a9f13559856e158196d8ed7f4e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 29 Nov 2022 13:34:21 -0500 Subject: Extract TargetPresentationGetter hierarchy These were inner classes of `ResolverListAdapter`, even though they're neither really related to the core responsibilities of that class, nor used exclusively in the implementation of that class (i.e., they're public APIs with clients in unrelated components of the app). This CL pulls the abstract base class (along with the two implementations) to a new component, and builds out comprehensive unit tests that address more edge-cases than the existing coverage in `ResolverActivityTest`. There are still opportunities to simplify the logic in the `TargetPresentationGetter` implementations, but this CL leaves the code roughly as-is so we can check in the unit test coverage in advance of any more significant refactorings. This CL: * Pulls the two implementations to static inner classes of their base class (the newly-non-inner-class) `TargetPresentationGetter`. * Introduces a new `TargetPresentationGetter.Factory` component to encapsulate the type-based overloads that were previously part of the `ResolverListAdapter` API. * Reworks client/test code to make calls in terms of the generic `TargetPresentationGetter` interface, then tightens the visibility of the subclasses (now private, instantiable only through the new `Factory` API). * Expands unit test coverage of the `TargetPresentationGetter` API, and removes the legacy tests from `ResolverActivityTest`. * Makes _some_ minor readability improvements (e.g. expanding acronyms in variable names) where convenient -- not consistently. Test: atest IntentResolverUnitTests Bug: 202167050 Change-Id: I4793f1149b698ddde8b3bbaa8bf23e0d7b962ce1 --- .../android/intentresolver/ChooserListAdapter.java | 4 +- .../ChooserTargetActionsDialogFragment.java | 10 +- .../intentresolver/ResolverListAdapter.java | 219 +---------------- .../intentresolver/TargetPresentationGetter.java | 267 +++++++++++++++++++++ .../intentresolver/chooser/DisplayResolveInfo.java | 28 +-- .../intentresolver/ChooserWrapperActivity.java | 3 +- .../android/intentresolver/IChooserWrapper.java | 3 +- .../intentresolver/ResolverActivityTest.java | 56 +---- .../intentresolver/ResolverDataProvider.java | 12 +- .../intentresolver/TargetPresentationGetterTest.kt | 204 ++++++++++++++++ 10 files changed, 513 insertions(+), 293 deletions(-) create mode 100644 java/src/com/android/intentresolver/TargetPresentationGetter.java create mode 100644 java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 12a054b9..699190f9 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -224,7 +224,7 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.icon = 0; } DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - ii, ri, ii, makePresentationGetter(ri)); + ii, ri, ii, mPresentationFactory.makePresentationGetter(ri)); mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } @@ -715,7 +715,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } // Now fetch app icon and raster with no badging even in work profile - Bitmap appIcon = makePresentationGetter(info).getIconBitmap(null); + Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null); // Raster target drawable with appIcon as a badge SimpleIconFactory sif = SimpleIconFactory.obtain(context); diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index f4d4a6d1..0aa32505 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -19,8 +19,6 @@ package com.android.intentresolver; import static android.content.Context.ACTIVITY_SERVICE; -import static com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; - import static java.util.stream.Collectors.toList; import android.annotation.NonNull; @@ -136,7 +134,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment ImageView icon = v.findViewById(com.android.internal.R.id.icon); RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer); - final ResolveInfoPresentationGetter pg = getProvidingAppPresentationGetter(); + final TargetPresentationGetter pg = getProvidingAppPresentationGetter(); title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel()); icon.setImageDrawable(pg.getIcon(mUserHandle)); rv.setAdapter(new VHAdapter(items)); @@ -270,14 +268,14 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment return getPinIcon(isPinned(dri)); } - private ResolveInfoPresentationGetter getProvidingAppPresentationGetter() { + private TargetPresentationGetter getProvidingAppPresentationGetter() { final ActivityManager am = (ActivityManager) getContext() .getSystemService(ACTIVITY_SERVICE); final int iconDpi = am.getLauncherLargeIconDensity(); // Use the matching application icon and label for the title, any TargetInfo will do - return new ResolveInfoPresentationGetter(getContext(), iconDpi, - mTargetInfos.get(0).getResolveInfo()); + return new TargetPresentationGetter.Factory(getContext(), iconDpi) + .makePresentationGetter(mTargetInfos.get(0).getResolveInfo()); } private boolean isPinned(DisplayResolveInfo dri) { diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 5513eda2..eecb914c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -26,15 +26,11 @@ import android.content.Context; import android.content.Intent; import android.content.PermissionChecker; import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; import android.content.pm.LabeledIntent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.graphics.Bitmap; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.RemoteException; @@ -74,6 +70,7 @@ public class ResolverListAdapter extends BaseAdapter { protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; protected final ResolverListController mResolverListController; + protected final TargetPresentationGetter.Factory mPresentationFactory; private final List mIntents; private final Intent[] mInitialIntents; @@ -126,6 +123,7 @@ public class ResolverListAdapter extends BaseAdapter { mIsAudioCaptureDevice = isAudioCaptureDevice; final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); mIconDpi = am.getLauncherLargeIconDensity(); + mPresentationFactory = new TargetPresentationGetter.Factory(mContext, mIconDpi); } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -479,7 +477,7 @@ public class ResolverListAdapter extends BaseAdapter { ri.loadLabel(mPm), null, ii, - makePresentationGetter(ri))); + mPresentationFactory.makePresentationGetter(ri))); } } @@ -532,7 +530,7 @@ public class ResolverListAdapter extends BaseAdapter { intent, add, (replaceIntent != null) ? replaceIntent : defaultIntent, - makePresentationGetter(add)); + mPresentationFactory.makePresentationGetter(add)); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -765,17 +763,9 @@ public class ResolverListAdapter extends BaseAdapter { return sSuspendedMatrixColorFilter; } - ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo ai) { - return new ActivityInfoPresentationGetter(mContext, mIconDpi, ai); - } - - ResolveInfoPresentationGetter makePresentationGetter(ResolveInfo ri) { - return new ResolveInfoPresentationGetter(mContext, mIconDpi, ri); - } - Drawable loadIconForResolveInfo(ResolveInfo ri) { // Load icons based on the current process. If in work profile icons should be badged. - return makePresentationGetter(ri).getIcon(getUserHandle()); + return mPresentationFactory.makePresentationGetter(ri).getIcon(getUserHandle()); } protected final Drawable loadIconPlaceholder() { @@ -875,8 +865,9 @@ public class ResolverListAdapter extends BaseAdapter { Intent replacementIntent = resolverListCommunicator.getReplacementIntent( resolveInfo.activityInfo, targetIntent); - ResolveInfoPresentationGetter presentationGetter = - new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo); + TargetPresentationGetter presentationGetter = + new TargetPresentationGetter.Factory(context, iconDpi) + .makePresentationGetter(resolveInfo); return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), @@ -979,8 +970,8 @@ public class ResolverListAdapter extends BaseAdapter { @Override protected CharSequence[] doInBackground(Void... voids) { - ResolveInfoPresentationGetter pg = - makePresentationGetter(mDisplayResolveInfo.getResolveInfo()); + TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( + mDisplayResolveInfo.getResolveInfo()); if (mIsAudioCaptureDevice) { // This is an audio capture device, so check record permissions @@ -1051,194 +1042,4 @@ public class ResolverListAdapter extends BaseAdapter { } } } - - /** - * Loads the icon and label for the provided ResolveInfo. - */ - @VisibleForTesting - public static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter { - private final ResolveInfo mRi; - public ResolveInfoPresentationGetter(Context ctx, int iconDpi, ResolveInfo ri) { - super(ctx, iconDpi, ri.activityInfo); - mRi = ri; - } - - @Override - Drawable getIconSubstituteInternal() { - Drawable dr = null; - try { - // Do not use ResolveInfo#getIconResource() as it defaults to the app - if (mRi.resolvePackageName != null && mRi.icon != 0) { - dr = loadIconFromResource( - mPm.getResourcesForApplication(mRi.resolvePackageName), mRi.icon); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " - + "couldn't find resources for package", e); - } - - // Fall back to ActivityInfo if no icon is found via ResolveInfo - if (dr == null) dr = super.getIconSubstituteInternal(); - - return dr; - } - - @Override - String getAppSubLabelInternal() { - // Will default to app name if no intent filter or activity label set, make sure to - // check if subLabel matches label before final display - return mRi.loadLabel(mPm).toString(); - } - - @Override - String getAppLabelForSubstitutePermission() { - // Will default to app name if no activity label set - return mRi.getComponentInfo().loadLabel(mPm).toString(); - } - } - - /** - * Loads the icon and label for the provided ActivityInfo. - */ - @VisibleForTesting - public static class ActivityInfoPresentationGetter extends - TargetPresentationGetter { - private final ActivityInfo mActivityInfo; - public ActivityInfoPresentationGetter(Context ctx, int iconDpi, - ActivityInfo activityInfo) { - super(ctx, iconDpi, activityInfo.applicationInfo); - mActivityInfo = activityInfo; - } - - @Override - Drawable getIconSubstituteInternal() { - Drawable dr = null; - try { - // Do not use ActivityInfo#getIconResource() as it defaults to the app - if (mActivityInfo.icon != 0) { - dr = loadIconFromResource( - mPm.getResourcesForApplication(mActivityInfo.applicationInfo), - mActivityInfo.icon); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " - + "couldn't find resources for package", e); - } - - return dr; - } - - @Override - String getAppSubLabelInternal() { - // Will default to app name if no activity label set, make sure to check if subLabel - // matches label before final display - return (String) mActivityInfo.loadLabel(mPm); - } - - @Override - String getAppLabelForSubstitutePermission() { - return getAppSubLabelInternal(); - } - } - - /** - * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application - * icon and label over any IntentFilter or Activity icon to increase user understanding, with an - * exception for applications that hold the right permission. Always attempts to use available - * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses - * Strings to strip creative formatting. - */ - private abstract static class TargetPresentationGetter { - @Nullable abstract Drawable getIconSubstituteInternal(); - @Nullable abstract String getAppSubLabelInternal(); - @Nullable abstract String getAppLabelForSubstitutePermission(); - - private Context mCtx; - private final int mIconDpi; - private final boolean mHasSubstitutePermission; - private final ApplicationInfo mAi; - - protected PackageManager mPm; - - TargetPresentationGetter(Context ctx, int iconDpi, ApplicationInfo ai) { - mCtx = ctx; - mPm = ctx.getPackageManager(); - mAi = ai; - mIconDpi = iconDpi; - mHasSubstitutePermission = PackageManager.PERMISSION_GRANTED == mPm.checkPermission( - android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON, - mAi.packageName); - } - - public Drawable getIcon(UserHandle userHandle) { - return new BitmapDrawable(mCtx.getResources(), getIconBitmap(userHandle)); - } - - public Bitmap getIconBitmap(@Nullable UserHandle userHandle) { - Drawable dr = null; - if (mHasSubstitutePermission) { - dr = getIconSubstituteInternal(); - } - - if (dr == null) { - try { - if (mAi.icon != 0) { - dr = loadIconFromResource(mPm.getResourcesForApplication(mAi), mAi.icon); - } - } catch (PackageManager.NameNotFoundException ignore) { - } - } - - // Fall back to ApplicationInfo#loadIcon if nothing has been loaded - if (dr == null) { - dr = mAi.loadIcon(mPm); - } - - SimpleIconFactory sif = SimpleIconFactory.obtain(mCtx); - Bitmap icon = sif.createUserBadgedIconBitmap(dr, userHandle); - sif.recycle(); - - return icon; - } - - public String getLabel() { - String label = null; - // Apps with the substitute permission will always show the activity label as the - // app label if provided - if (mHasSubstitutePermission) { - label = getAppLabelForSubstitutePermission(); - } - - if (label == null) { - label = (String) mAi.loadLabel(mPm); - } - - return label; - } - - public String getSubLabel() { - // Apps with the substitute permission will always show the resolve info label as the - // sublabel if provided - if (mHasSubstitutePermission){ - String appSubLabel = getAppSubLabelInternal(); - // Use the resolve info label as sublabel if it is set - if(!TextUtils.isEmpty(appSubLabel) - && !TextUtils.equals(appSubLabel, getLabel())){ - return appSubLabel; - } - return null; - } - return getAppSubLabelInternal(); - } - - protected String loadLabelFromResource(Resources res, int resId) { - return res.getString(resId); - } - - @Nullable - protected Drawable loadIconFromResource(Resources res, int resId) { - return res.getDrawableForDensity(resId, mIconDpi); - } - - } } diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java new file mode 100644 index 00000000..f8b36566 --- /dev/null +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2022 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.intentresolver; + +import android.annotation.Nullable; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.Log; + +/** + * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon + * and label over any IntentFilter or Activity icon to increase user understanding, with an + * exception for applications that hold the right permission. Always attempts to use available + * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses + * Strings to strip creative formatting. + * + * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the + * appropriate concrete type. + * + * TODO: once this component (and its tests) are merged, it should be possible to refactor and + * vastly simplify by precomputing conditional logic at initialization. + */ +public abstract class TargetPresentationGetter { + private static final String TAG = "ResolverListAdapter"; + + /** Helper to build appropriate type-specific {@link TargetPresentationGetter} instances. */ + public static class Factory { + private final Context mContext; + private final int mIconDpi; + + public Factory(Context context, int iconDpi) { + mContext = context; + mIconDpi = iconDpi; + } + + /** Make a {@link TargetPresentationGetter} for an {@link ActivityInfo}. */ + public TargetPresentationGetter makePresentationGetter(ActivityInfo activityInfo) { + return new ActivityInfoPresentationGetter(mContext, mIconDpi, activityInfo); + } + + /** Make a {@link TargetPresentationGetter} for a {@link ResolveInfo}. */ + public TargetPresentationGetter makePresentationGetter(ResolveInfo resolveInfo) { + return new ResolveInfoPresentationGetter(mContext, mIconDpi, resolveInfo); + } + } + + @Nullable + protected abstract Drawable getIconSubstituteInternal(); + + @Nullable + protected abstract String getAppSubLabelInternal(); + + @Nullable + protected abstract String getAppLabelForSubstitutePermission(); + + private Context mContext; + private final int mIconDpi; + private final boolean mHasSubstitutePermission; + private final ApplicationInfo mAppInfo; + + protected PackageManager mPm; + + /** + * Retrieve the image that should be displayed as the icon when this target is presented to the + * specified {@code userHandle}. + */ + public Drawable getIcon(UserHandle userHandle) { + return new BitmapDrawable(mContext.getResources(), getIconBitmap(userHandle)); + } + + /** + * Retrieve the image that should be displayed as the icon when this target is presented to the + * specified {@code userHandle}. + */ + public Bitmap getIconBitmap(@Nullable UserHandle userHandle) { + Drawable drawable = null; + if (mHasSubstitutePermission) { + drawable = getIconSubstituteInternal(); + } + + if (drawable == null) { + try { + if (mAppInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mAppInfo), mAppInfo.icon); + } + } catch (PackageManager.NameNotFoundException ignore) { } + } + + // Fall back to ApplicationInfo#loadIcon if nothing has been loaded + if (drawable == null) { + drawable = mAppInfo.loadIcon(mPm); + } + + SimpleIconFactory iconFactory = SimpleIconFactory.obtain(mContext); + Bitmap icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle); + iconFactory.recycle(); + + return icon; + } + + /** Get the label to display for the target. */ + public String getLabel() { + String label = null; + // Apps with the substitute permission will always show the activity label as the app label + // if provided. + if (mHasSubstitutePermission) { + label = getAppLabelForSubstitutePermission(); + } + + if (label == null) { + label = (String) mAppInfo.loadLabel(mPm); + } + + return label; + } + + /** + * Get the sublabel to display for the target. Clients are responsible for deduping their + * presentation if this returns the same value as {@link #getLabel()}. + * TODO: this class should take responsibility for that deduping internally so it's an + * authoritative record of exactly the content that should be presented. + */ + public String getSubLabel() { + // Apps with the substitute permission will always show the resolve info label as the + // sublabel if provided + if (mHasSubstitutePermission) { + String appSubLabel = getAppSubLabelInternal(); + // Use the resolve info label as sublabel if it is set + if (!TextUtils.isEmpty(appSubLabel) && !TextUtils.equals(appSubLabel, getLabel())) { + return appSubLabel; + } + return null; + } + return getAppSubLabelInternal(); + } + + protected String loadLabelFromResource(Resources res, int resId) { + return res.getString(resId); + } + + @Nullable + protected Drawable loadIconFromResource(Resources res, int resId) { + return res.getDrawableForDensity(resId, mIconDpi); + } + + private TargetPresentationGetter(Context context, int iconDpi, ApplicationInfo appInfo) { + mContext = context; + mPm = context.getPackageManager(); + mAppInfo = appInfo; + mIconDpi = iconDpi; + mHasSubstitutePermission = (PackageManager.PERMISSION_GRANTED == mPm.checkPermission( + android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON, + mAppInfo.packageName)); + } + + /** Loads the icon and label for the provided ResolveInfo. */ + private static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter { + private final ResolveInfo mResolveInfo; + + ResolveInfoPresentationGetter( + Context context, int iconDpi, ResolveInfo resolveInfo) { + super(context, iconDpi, resolveInfo.activityInfo); + mResolveInfo = resolveInfo; + } + + @Override + protected Drawable getIconSubstituteInternal() { + Drawable drawable = null; + try { + // Do not use ResolveInfo#getIconResource() as it defaults to the app + if (mResolveInfo.resolvePackageName != null && mResolveInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mResolveInfo.resolvePackageName), + mResolveInfo.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + // Fall back to ActivityInfo if no icon is found via ResolveInfo + if (drawable == null) { + drawable = super.getIconSubstituteInternal(); + } + + return drawable; + } + + @Override + protected String getAppSubLabelInternal() { + // Will default to app name if no intent filter or activity label set, make sure to + // check if subLabel matches label before final display + return mResolveInfo.loadLabel(mPm).toString(); + } + + @Override + protected String getAppLabelForSubstitutePermission() { + // Will default to app name if no activity label set + return mResolveInfo.getComponentInfo().loadLabel(mPm).toString(); + } + } + + /** Loads the icon and label for the provided {@link ActivityInfo}. */ + private static class ActivityInfoPresentationGetter extends TargetPresentationGetter { + private final ActivityInfo mActivityInfo; + + ActivityInfoPresentationGetter( + Context context, int iconDpi, ActivityInfo activityInfo) { + super(context, iconDpi, activityInfo.applicationInfo); + mActivityInfo = activityInfo; + } + + @Override + protected Drawable getIconSubstituteInternal() { + Drawable drawable = null; + try { + // Do not use ActivityInfo#getIconResource() as it defaults to the app + if (mActivityInfo.icon != 0) { + drawable = loadIconFromResource( + mPm.getResourcesForApplication(mActivityInfo.applicationInfo), + mActivityInfo.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + return drawable; + } + + @Override + protected String getAppSubLabelInternal() { + // Will default to app name if no activity label set, make sure to check if subLabel + // matches label before final display + return (String) mActivityInfo.loadLabel(mPm); + } + + @Override + protected String getAppLabelForSubstitutePermission() { + return getAppSubLabelInternal(); + } + } +} diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index c1b007af..db5ae0b4 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -28,7 +28,7 @@ import android.os.Bundle; import android.os.UserHandle; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; +import com.android.intentresolver.TargetPresentationGetter; import java.util.ArrayList; import java.util.Arrays; @@ -45,7 +45,7 @@ public class DisplayResolveInfo implements TargetInfo { private final Intent mResolvedIntent; private final List mSourceIntents = new ArrayList<>(); private final boolean mIsSuspended; - private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; + private TargetPresentationGetter mPresentationGetter; private boolean mPinned = false; private final IconHolder mDisplayIconHolder = new SettableIconHolder(); @@ -54,7 +54,7 @@ public class DisplayResolveInfo implements TargetInfo { Intent originalIntent, ResolveInfo resolveInfo, @NonNull Intent resolvedIntent, - @Nullable ResolveInfoPresentationGetter presentationGetter) { + @Nullable TargetPresentationGetter presentationGetter) { return newDisplayResolveInfo( originalIntent, resolveInfo, @@ -71,14 +71,14 @@ public class DisplayResolveInfo implements TargetInfo { CharSequence displayLabel, CharSequence extendedInfo, @NonNull Intent resolvedIntent, - @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + @Nullable TargetPresentationGetter presentationGetter) { return new DisplayResolveInfo( originalIntent, resolveInfo, displayLabel, extendedInfo, resolvedIntent, - resolveInfoPresentationGetter); + presentationGetter); } private DisplayResolveInfo( @@ -87,12 +87,12 @@ public class DisplayResolveInfo implements TargetInfo { CharSequence displayLabel, CharSequence extendedInfo, @NonNull Intent resolvedIntent, - @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + @Nullable TargetPresentationGetter presentationGetter) { mSourceIntents.add(originalIntent); mResolveInfo = resolveInfo; mDisplayLabel = displayLabel; mExtendedInfo = extendedInfo; - mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + mPresentationGetter = presentationGetter; final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; @@ -108,7 +108,7 @@ public class DisplayResolveInfo implements TargetInfo { DisplayResolveInfo other, Intent fillInIntent, int flags, - ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + TargetPresentationGetter presentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; @@ -116,7 +116,7 @@ public class DisplayResolveInfo implements TargetInfo { mExtendedInfo = other.mExtendedInfo; mResolvedIntent = new Intent(other.mResolvedIntent); mResolvedIntent.fillIn(fillInIntent, flags); - mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -128,7 +128,7 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; - mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; + mPresentationGetter = other.mPresentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -143,9 +143,9 @@ public class DisplayResolveInfo implements TargetInfo { } public CharSequence getDisplayLabel() { - if (mDisplayLabel == null && mResolveInfoPresentationGetter != null) { - mDisplayLabel = mResolveInfoPresentationGetter.getLabel(); - mExtendedInfo = mResolveInfoPresentationGetter.getSubLabel(); + if (mDisplayLabel == null && mPresentationGetter != null) { + mDisplayLabel = mPresentationGetter.getLabel(); + mExtendedInfo = mPresentationGetter.getSubLabel(); } return mDisplayLabel; } @@ -169,7 +169,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new DisplayResolveInfo(this, fillInIntent, flags, mResolveInfoPresentationGetter); + return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter); } @Override diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 04e727ba..60320509 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -38,7 +38,6 @@ import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; -import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -256,7 +255,7 @@ public class ChooserWrapperActivity @Override public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + @Nullable TargetPresentationGetter resolveInfoPresentationGetter) { return DisplayResolveInfo.newDisplayResolveInfo( originalIntent, pri, diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index 0d44e147..af897a47 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -22,7 +22,6 @@ import android.content.Intent; import android.content.pm.ResolveInfo; import android.os.UserHandle; -import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.concurrent.Executor; @@ -40,7 +39,7 @@ public interface IChooserWrapper { UsageStatsManager getUsageStatsManager(); DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter); + @Nullable TargetPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); ChooserActivityLogger getChooserActivityLogger(); Executor getMainExecutor(); diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 07cbd6a4..62c16ff5 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -27,7 +27,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.android.intentresolver.MatcherUtils.first; -import static com.android.intentresolver.ResolverDataProvider.createPackageManagerMockedInfo; import static com.android.intentresolver.ResolverWrapperActivity.sOverrides; import static org.hamcrest.CoreMatchers.allOf; @@ -39,34 +38,25 @@ import static org.mockito.Mockito.when; import static org.testng.Assert.assertFalse; import static org.testng.Assert.fail; -import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; import android.net.Uri; -import android.os.Bundle; -import android.os.Parcelable; import android.os.RemoteException; import android.os.UserHandle; import android.text.TextUtils; -import android.util.Log; import android.view.View; import android.widget.RelativeLayout; import android.widget.TextView; import androidx.test.InstrumentationRegistry; -import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.Espresso; import androidx.test.espresso.NoMatchingViewException; -import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; -import com.android.internal.R; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; -import com.android.intentresolver.ResolverDataProvider.PackageManagerMockedInfo; -import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; -import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.android.internal.R; import org.junit.Before; import org.junit.Ignore; @@ -363,50 +353,6 @@ public class ResolverActivityTest { assertThat(chosen[0], is(toChoose)); } - @Test - public void getActivityLabelAndSubLabel() throws Exception { - ActivityInfoPresentationGetter pg; - PackageManagerMockedInfo info; - - info = createPackageManagerMockedInfo(false); - pg = new ActivityInfoPresentationGetter( - info.ctx, 0, info.activityInfo); - assertThat("Label should match app label", pg.getLabel().equals( - info.setAppLabel)); - assertThat("Sublabel should match activity label if set", - pg.getSubLabel().equals(info.setActivityLabel)); - - info = createPackageManagerMockedInfo(true); - pg = new ActivityInfoPresentationGetter( - info.ctx, 0, info.activityInfo); - assertThat("With override permission label should match activity label if set", - pg.getLabel().equals(info.setActivityLabel)); - assertThat("With override permission sublabel should be empty", - TextUtils.isEmpty(pg.getSubLabel())); - } - - @Test - public void getResolveInfoLabelAndSubLabel() throws Exception { - ResolveInfoPresentationGetter pg; - PackageManagerMockedInfo info; - - info = createPackageManagerMockedInfo(false); - pg = new ResolveInfoPresentationGetter( - info.ctx, 0, info.resolveInfo); - assertThat("Label should match app label", pg.getLabel().equals( - info.setAppLabel)); - assertThat("Sublabel should match resolve info label if set", - pg.getSubLabel().equals(info.setResolveInfoLabel)); - - info = createPackageManagerMockedInfo(true); - pg = new ResolveInfoPresentationGetter( - info.ctx, 0, info.resolveInfo); - assertThat("With override permission label should match activity label if set", - pg.getLabel().equals(info.setActivityLabel)); - assertThat("With override permission the sublabel should be the resolve info label", - pg.getSubLabel().equals(info.setResolveInfoLabel)); - } - @Test public void testWorkTab_displayedWhenWorkProfileUserAvailable() { Intent sendIntent = createSendImageIntent(); diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 01d07639..fb928e09 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -93,11 +93,17 @@ public class ResolverDataProvider { public String setResolveInfoLabel; } + /** Create a {@link PackageManagerMockedInfo} with all distinct labels. */ static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) { - final String appLabel = "app_label"; - final String activityLabel = "activity_label"; - final String resolveInfoLabel = "resolve_info_label"; + return createPackageManagerMockedInfo( + hasOverridePermission, "app_label", "activity_label", "resolve_info_label"); + } + static PackageManagerMockedInfo createPackageManagerMockedInfo( + boolean hasOverridePermission, + String appLabel, + String activityLabel, + String resolveInfoLabel) { MockContext ctx = new MockContext() { @Override public PackageManager getPackageManager() { diff --git a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt new file mode 100644 index 00000000..e62672a3 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import com.android.intentresolver.ResolverDataProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the various implementations of {@link TargetPresentationGetter}. + * TODO: consider expanding to cover icon logic (not just labels/sublabels). + * TODO: these are conceptually "acceptance tests" that provide comprehensive coverage of the + * apparent variations in the legacy implementation. The tests probably don't have to be so + * exhaustive if we're able to impose a simpler design on the implementation. + */ +class TargetPresentationGetterTest { + fun makeResolveInfoPresentationGetter( + withSubstitutePermission: Boolean, + appLabel: String, + activityLabel: String, + resolveInfoLabel: String): TargetPresentationGetter { + val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( + withSubstitutePermission, appLabel, activityLabel, resolveInfoLabel) + val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) + return factory.makePresentationGetter(testPackageInfo.resolveInfo) + } + + fun makeActivityInfoPresentationGetter( + withSubstitutePermission: Boolean, + appLabel: String?, + activityLabel: String?): TargetPresentationGetter { + val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( + withSubstitutePermission, appLabel, activityLabel, "") + val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) + return factory.makePresentationGetter(testPackageInfo.activityInfo) + } + + @Test + fun testActivityInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter( + false, "app_label", "activity_label") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") + } + + @Test + fun testActivityInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter( + false, "app_label", "app_label") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // Without the substitute permission, there's no logic to dedupe the labels. + // TODO: this matches our observations in the legacy code, but is it the right behavior? It + // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in + // the UI at least, but maybe that logic should be pulled back to the "presentation"? + assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label") + } + + @Test + fun testActivityInfoLabels_noSubstitutePermission_nullRequestedLabel() { + val presentationGetter = makeActivityInfoPresentationGetter(false, null, "activity_label") + assertThat(presentationGetter.getLabel()).isNull() + assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") + } + + @Test + fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedLabel() { + val presentationGetter = makeActivityInfoPresentationGetter(false, "", "activity_label") + assertThat(presentationGetter.getLabel()).isEqualTo("") + assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") + } + + @Test + fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // Without the substitute permission, empty sublabels are passed through as-is. + assertThat(presentationGetter.getSubLabel()).isEqualTo("") + } + + @Test + fun testActivityInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter( + true, "app_label", "activity_label") + assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") + // With the substitute permission, the same ("activity") label is requested as both the label + // and sublabel, even though the other value ("app_label") was distinct. Thus this behaves the + // same as a dupe. + assertThat(presentationGetter.getSubLabel()).isEqualTo(null) + } + + @Test + fun testActivityInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter( + true, "app_label", "app_label") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // With the substitute permission, duped sublabels get converted to nulls. + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testActivityInfoLabels_withSubstitutePermission_nullRequestedLabel() { + val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", null) + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // With the substitute permission, null inputs are a special case that produces null outputs + // (i.e., they're not simply passed-through from the inputs). + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedLabel() { + val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "") + // Empty "labels" are taken as-is and (unlike nulls) don't prompt a fallback to the sublabel. + // Thus (as in the previous case with substitute permission & "distinct" labels), this is + // treated as a dupe. + assertThat(presentationGetter.getLabel()).isEqualTo("") + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedSublabel() { + val presentationGetter = makeActivityInfoPresentationGetter(true, "", "activity_label") + assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") + // With the substitute permission, empty sublabels get converted to nulls. + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testResolveInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + false, "app_label", "activity_label", "resolve_info_label") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") + } + + @Test + fun testResolveInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + false, "app_label", "activity_label", "app_label") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // Without the substitute permission, there's no logic to dedupe the labels. + // TODO: this matches our observations in the legacy code, but is it the right behavior? It + // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in + // the UI at least, but maybe that logic should be pulled back to the "presentation"? + assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label") + } + + @Test + fun testResolveInfoLabels_noSubstitutePermission_emptyRequestedSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + false, "app_label", "activity_label", "") + assertThat(presentationGetter.getLabel()).isEqualTo("app_label") + // Without the substitute permission, empty sublabels are passed through as-is. + assertThat(presentationGetter.getSubLabel()).isEqualTo("") + } + + @Test + fun testResolveInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + true, "app_label", "activity_label", "resolve_info_label") + assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") + assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") + } + + @Test + fun testResolveInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + true, "app_label", "activity_label", "activity_label") + assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") + // With the substitute permission, duped sublabels get converted to nulls. + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + true, "app_label", "activity_label", "") + assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") + // With the substitute permission, empty sublabels get converted to nulls. + assertThat(presentationGetter.getSubLabel()).isNull() + } + + @Test + fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedLabelAndSublabel() { + val presentationGetter = makeResolveInfoPresentationGetter( + true, "app_label", "", "") + assertThat(presentationGetter.getLabel()).isEqualTo("") + // With the substitute permission, empty sublabels get converted to nulls. + assertThat(presentationGetter.getSubLabel()).isNull() + } +} -- cgit v1.2.3-59-g8ed1b From 9a7d6f01264a6167c246afcad540785ea59431ca Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 15 Dec 2022 14:51:54 -0800 Subject: Split ActionRow view into internface and implementation ActionRow widget is split into an interface, ActionRow, an implementation, ChooserActionRow. The view is continue to be referenced by the interface everywhere in the codae. Bug: 262278109 Test: manual testing for basic functionality Test: atest IntentResolverUnitTests Change-Id: Idadaaf80ea62f655719b40b10b9d23188c0e590d --- java/res/layout/chooser_action_row.xml | 2 +- .../com/android/intentresolver/widget/ActionRow.kt | 64 +---------------- .../intentresolver/widget/ChooserActionRow.kt | 81 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 63 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ChooserActionRow.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index fd47155c..a6b1e813 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -14,7 +14,7 @@ ~ limitations under the License --> - = emptyList() - - override fun onRestoreInstanceState(state: Parcelable?) { - super.onRestoreInstanceState(state) - setActions(actions) - } - - fun setActions(actions: List) { - removeAllViews() - this.actions = ArrayList(actions) - for (action in actions) { - addAction(action) - } - } - - private fun addAction(action: Action) { - val b = LayoutInflater.from(context).inflate(itemLayout, null) as Button - if (action.icon != null) { - val size = resources - .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size) - action.icon.setBounds(0, 0, size, size) - b.setCompoundDrawablesRelative(action.icon, null, null, null) - } - b.text = action.label ?: "" - b.setOnClickListener { - action.onClicked.run() - } - b.id = action.id - addView(b) - } - - override fun generateDefaultLayoutParams(): LayoutParams = - super.generateDefaultLayoutParams().apply { - setMarginsRelative(itemMargin, 0, itemMargin, 0) - } +interface ActionRow { + fun setActions(actions: List) class Action @JvmOverloads constructor( // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we diff --git a/java/src/com/android/intentresolver/widget/ChooserActionRow.kt b/java/src/com/android/intentresolver/widget/ChooserActionRow.kt new file mode 100644 index 00000000..a4656bb5 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserActionRow.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2022 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.intentresolver.widget + +import android.annotation.LayoutRes +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.LinearLayout +import com.android.intentresolver.R +import com.android.intentresolver.widget.ActionRow.Action + +class ChooserActionRow : LinearLayout, ActionRow { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) { + orientation = HORIZONTAL + } + + @LayoutRes + private val itemLayout = R.layout.chooser_action_button + private val itemMargin = + context.resources.getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2 + private var actions: List = emptyList() + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + setActions(actions) + } + + override fun setActions(actions: List) { + removeAllViews() + this.actions = ArrayList(actions) + for (action in actions) { + addAction(action) + } + } + + private fun addAction(action: Action) { + val b = LayoutInflater.from(context).inflate(itemLayout, null) as Button + if (action.icon != null) { + val size = resources + .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size) + action.icon.setBounds(0, 0, size, size) + b.setCompoundDrawablesRelative(action.icon, null, null, null) + } + b.text = action.label ?: "" + b.setOnClickListener { + action.onClicked.run() + } + b.id = action.id + addView(b) + } + + override fun generateDefaultLayoutParams(): LayoutParams = + super.generateDefaultLayoutParams().apply { + setMarginsRelative(itemMargin, 0, itemMargin, 0) + } +} -- cgit v1.2.3-59-g8ed1b From 68163c09ae1bfd8ebeaabfd21b78650b2ede00df Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 19 Dec 2022 09:02:11 -0800 Subject: A base scrollable action row implementation A base implementation of the new scrollable action row; the view is added to the codebase but not used. Bug: 262278109 Test: Tested with some debug code injections (base functionality, a11y, unit tests). Change-Id: Ic109440ce9609e73b72fc86c2cd455c42a421103 --- java/res/layout/chooser_action_view.xml | 28 +++++ java/res/values/dimens.xml | 2 + .../intentresolver/widget/ScrollableActionRow.kt | 120 +++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 java/res/layout/chooser_action_view.xml create mode 100644 java/src/com/android/intentresolver/widget/ScrollableActionRow.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml new file mode 100644 index 00000000..d74798e2 --- /dev/null +++ b/java/res/layout/chooser_action_view.xml @@ -0,0 +1,28 @@ + + + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 2d6fe816..93cb4637 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -50,4 +50,6 @@ 4dp 18dp + 22dp + 0dp diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt new file mode 100644 index 00000000..f3a34985 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2022 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.intentresolver.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.intentresolver.R + +class ScrollableActionRow : RecyclerView, ActionRow { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = Adapter(context) + } + + private val actionsAdapter get() = adapter as Adapter + + override fun setActions(actions: List) { + actionsAdapter.setActions(actions) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + setOverScrollMode( + if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS + ) + } + + private val areAllChildrenVisible: Boolean + get() { + val count = getChildCount() + if (count == 0) return true + val first = getChildAt(0) + val last = getChildAt(count - 1) + return getChildAdapterPosition(first) == 0 + && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1 + && isFullyVisible(first) + && isFullyVisible(last) + } + + private fun isFullyVisible(view: View): Boolean = + view.left >= paddingLeft && view.right <= width - paddingRight + + private class Adapter(private val context: Context) : RecyclerView.Adapter() { + private val iconSize: Int = + context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size) + private val itemLayout = R.layout.chooser_action_view + private var actions: List = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): ViewHolder = + ViewHolder( + LayoutInflater.from(context).inflate(itemLayout, null) as TextView, + iconSize + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(actions[position]) + } + + override fun getItemCount() = actions.size + + override fun onViewRecycled(holder: ViewHolder) { + holder.unbind() + } + + override fun onFailedToRecycleView(holder: ViewHolder): Boolean { + holder.unbind() + return super.onFailedToRecycleView(holder) + } + + fun setActions(actions: List) { + this.actions = ArrayList(actions) + notifyDataSetChanged() + } + } + + private class ViewHolder( + private val view: TextView, private val iconSize: Int + ) : RecyclerView.ViewHolder(view) { + + fun bind(action: ActionRow.Action) { + if (action.icon != null) { + action.icon.setBounds(0, 0, iconSize, iconSize) + view.setCompoundDrawablesRelative(null, action.icon, null, null) + } + view.text = action.label ?: "" + view.setOnClickListener { + action.onClicked.run() + } + view.id = action.id + } + + fun unbind() { + view.setOnClickListener(null) + } + } +} -- cgit v1.2.3-59-g8ed1b From 4f282042d84e40a9da7183c2d908e90c83b689ab Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 21 Dec 2022 11:20:04 -0800 Subject: Add a mechanism to switch between action row view varians Use ViewStub to lazy inflate actin row view variant based on an argument; will be used with a feature flag. Colateraly, add a workaround for icon tinting issue (commented in the code) for the new scrollable action row. Bug: 262278109 Test: manual tests that the existing layout visually statys the same in all layout variants. Change-Id: I289949e94864cb96c81c722eb3a39887cc130779 --- java/res/layout/chooser_action_row.xml | 21 ++++++++---- java/res/layout/chooser_grid_preview_file.xml | 13 +++----- java/res/layout/chooser_grid_preview_image.xml | 12 +++---- java/res/layout/chooser_grid_preview_text.xml | 12 +++---- java/res/layout/scrollable_chooser_action_row.xml | 30 +++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 1 + .../intentresolver/ChooserContentPreviewUi.java | 39 +++++++++++++++------- .../intentresolver/widget/ScrollableActionRow.kt | 10 ++++++ 8 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 java/res/layout/scrollable_chooser_action_row.xml (limited to 'java/src') diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index a6b1e813..620ff704 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -13,11 +13,20 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License --> - - + android:layout_height="wrap_content"> + + + + \ No newline at end of file diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index c3392704..e98c3273 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -68,13 +68,10 @@ android:singleLine="true"/> - + + diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 4d15bf75..9d1dc208 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -80,14 +80,10 @@ - + diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index 81fdbd08..db7282e3 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -52,14 +52,10 @@ - + diff --git a/java/res/layout/scrollable_chooser_action_row.xml b/java/res/layout/scrollable_chooser_action_row.xml new file mode 100644 index 00000000..cb5dabf0 --- /dev/null +++ b/java/res/layout/scrollable_chooser_action_row.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c7470ab2..83cdc9ef 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -776,6 +776,7 @@ public class ChooserActivity extends ResolverActivity implements getResources(), getLayoutInflater(), actionFactory, + R.layout.chooser_action_row, parent, previewCoordinator, getContentResolver(), diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index f9f4ee98..0cadce4b 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -34,9 +34,11 @@ import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewStub; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import com.android.intentresolver.widget.ActionRow; @@ -177,6 +179,7 @@ public final class ChooserContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ActionFactory actionFactory, + @LayoutRes int actionRowLayout, ViewGroup parent, ContentPreviewCoordinator previewCoord, ContentResolver contentResolver, @@ -190,7 +193,8 @@ public final class ChooserContentPreviewUi { layoutInflater, createTextPreviewActions(actionFactory), parent, - previewCoord); + previewCoord, + actionRowLayout); break; case CONTENT_PREVIEW_IMAGE: layout = displayImageContentPreview( @@ -200,7 +204,8 @@ public final class ChooserContentPreviewUi { parent, previewCoord, contentResolver, - imageClassifier); + imageClassifier, + actionRowLayout); break; case CONTENT_PREVIEW_FILE: layout = displayFileContentPreview( @@ -210,7 +215,8 @@ public final class ChooserContentPreviewUi { createFilePreviewActions(actionFactory), parent, previewCoord, - contentResolver); + contentResolver, + actionRowLayout); break; default: Log.e(TAG, "Unexpected content preview type: " + previewType); @@ -239,12 +245,12 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord) { + ContentPreviewCoordinator previewCoord, + @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); - final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { actionRow.setActions(actions); } @@ -312,14 +318,14 @@ public final class ChooserContentPreviewUi { ViewGroup parent, ContentPreviewCoordinator previewCoord, ContentResolver contentResolver, - ImageMimeTypeClassifier imageClassifier) { + ImageMimeTypeClassifier imageClassifier, + @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ViewGroup imagePreview = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_image_area); - final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { actionRow.setActions(actions); } @@ -403,12 +409,12 @@ public final class ChooserContentPreviewUi { List actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, - ContentResolver contentResolver) { + ContentResolver contentResolver, + @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { actionRow.setActions(actions); } @@ -468,6 +474,15 @@ public final class ChooserContentPreviewUi { return actions; } + private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { + final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub); + if (stub != null) { + stub.setLayoutResource(actionRowLayout); + stub.inflate(); + } + return parent.findViewById(com.android.internal.R.id.chooser_action_row); + } + private static void logContentPreviewWarning(Uri uri) { // The ContentResolver already logs the exception. Log something more informative. Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt index f3a34985..a941b97a 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.widget import android.content.Context +import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View @@ -104,6 +105,8 @@ class ScrollableActionRow : RecyclerView, ActionRow { fun bind(action: ActionRow.Action) { if (action.icon != null) { action.icon.setBounds(0, 0, iconSize, iconSize) + // some drawables (edit) does not gets tinted when set to the top of the text + // with TextView#setCompoundDrawableRelative view.setCompoundDrawablesRelative(null, action.icon, null, null) } view.text = action.label ?: "" @@ -116,5 +119,12 @@ class ScrollableActionRow : RecyclerView, ActionRow { fun unbind() { view.setOnClickListener(null) } + + private fun tintIcon(drawable: Drawable, view: TextView) { + val tintList = view.compoundDrawableTintList ?: return + drawable.setTintList(tintList) + view.compoundDrawableTintMode?.let { drawable.setTintMode(it) } + view.compoundDrawableTintBlendMode?.let { drawable.setTintBlendMode(it) } + } } } -- cgit v1.2.3-59-g8ed1b From fb2ee59060fc15a659ea859cee3466035f6b4963 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 23 Dec 2022 15:45:43 -0800 Subject: Introduce an image preview view A preparation step for image preview UI update. A new view, ImagePreviewView, that encapsulates image preview grid UI is created. The view has a generalized interface for image loading: it accepts a list of image URIs and a suspend function that would perform actual image loading. The image loading is still delegated to ChooserContentPreviewCoordinator class that has the following collateral changes: - all UI logic is moved out of the class either into the new view or into ChooserContentPreviewUI; - mOnSingleImageSuccessCallback is removed and replaced with a separate callback (see description below). ChooserActivity used ChooserContentPreviewCoordinator#mOnSingleImageSuccess as a signal to start lisneting for the shared element transition animation (SETA) target readiness. With all image preview UI logic now being encapsulated in the new ImagePreviewView view, the new view triggers a SETA target readiness callback instead, if configured. As ChooserActivity explicitely resumes SETA for a non-image preview, ChooserContentPreviewCoordinator always triggers image callback and the new view always notify about SETA target readiness, we should be fine with remplacing the after-image-loaded callback. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual tests for all previe types, multi- and single profile and SETA (share a shcreenshot) tests. Test: delay image loading and test that in all cases SETA is not get stuck. Test: atest IntentResolverUnitTests Change-Id: I081ab98c2bcb24cd2ad96b508ab559d7775aeaf4 --- Android.bp | 6 + java/res/layout/chooser_grid_preview_image.xml | 53 +----- java/res/layout/image_preview_view.xml | 72 +++++++++ .../android/intentresolver/ChooserActivity.java | 28 ++-- .../ChooserContentPreviewCoordinator.java | 90 +++-------- .../intentresolver/ChooserContentPreviewUi.java | 113 +++++++------ .../intentresolver/ImagePreviewImageLoader.kt | 38 +++++ .../intentresolver/widget/ImagePreviewView.kt | 178 +++++++++++++++++++++ .../widget/RoundedRectImageView.java | 1 + 9 files changed, 384 insertions(+), 195 deletions(-) create mode 100644 java/res/layout/image_preview_view.xml create mode 100644 java/src/com/android/intentresolver/ImagePreviewImageLoader.kt create mode 100644 java/src/com/android/intentresolver/widget/ImagePreviewView.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 521d5626..31d7d6d0 100644 --- a/Android.bp +++ b/Android.bp @@ -51,6 +51,12 @@ android_library { "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", + "androidx.lifecycle_lifecycle-viewmodel-ktx", + "kotlin-stdlib", + "kotlinx_coroutines", + "kotlinx-coroutines-android", + "//external/kotlinc:kotlin-annotations", "guava", ], diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 9d1dc208..5c324140 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -24,61 +24,14 @@ android:layout_height="wrap_content" android:orientation="vertical" android:background="?android:attr/colorBackground"> - - - - - - - - - - - + android:background="?android:attr/colorBackground" /> + + + + + + + + + + + + diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 83cdc9ef..df71c7ff 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -259,23 +259,17 @@ public class ChooserActivity extends ResolverActivity implements public ChooserActivity() {} - private void setupPreDrawForSharedElementTransition(View v) { - v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - v.getViewTreeObserver().removeOnPreDrawListener(this); - - if (!mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the transition animation. - getWindow().setWindowAnimations(0); - } - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - return true; - } - }); + private void onSharedElementTransitionTargetReady(boolean runTransitionAnimation) { + if (runTransitionAnimation && !mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the transition animation. + getWindow().setWindowAnimations(0); + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } else { + onSharedElementTransitionTargetMissing(); + } } - private void hideContentPreview() { + private void onSharedElementTransitionTargetMissing() { mRemoveSharedElements = true; mEnterTransitionAnimationDelegate.markImagePreviewReady(); } @@ -318,8 +312,7 @@ public class ChooserActivity extends ResolverActivity implements mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, this, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); + this::onSharedElementTransitionTargetMissing); super.onCreate( savedInstanceState, @@ -779,6 +772,7 @@ public class ChooserActivity extends ResolverActivity implements R.layout.chooser_action_row, parent, previewCoordinator, + this::onSharedElementTransitionTargetReady, getContentResolver(), this::isImageType); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java index fdc58170..93552e31 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -16,28 +16,20 @@ package com.android.intentresolver; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.annotation.NonNull; import android.graphics.Bitmap; import android.net.Uri; import android.os.Handler; import android.util.Size; -import android.view.View; -import android.view.animation.DecelerateInterpolator; import androidx.annotation.MainThread; import androidx.annotation.Nullable; -import com.android.intentresolver.widget.RoundedRectImageView; - import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; @@ -50,25 +42,23 @@ public class ChooserContentPreviewCoordinator implements public ChooserContentPreviewCoordinator( ExecutorService backgroundExecutor, ChooserActivity chooserActivity, - Runnable onFailCallback, - Consumer onSingleImageSuccessCallback) { + Runnable onFailCallback) { this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); this.mChooserActivity = chooserActivity; this.mOnFailCallback = onFailCallback; - this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; this.mImageLoadTimeoutMillis = chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); } @Override - public void loadUriIntoView( - final Callable deferredImageViewProvider, - final Uri imageUri, - final int extraImageCount) { + public void loadImage(final Uri imageUri, final Consumer callback) { final int size = mChooserActivity.getResources().getDimensionPixelSize( R.dimen.chooser_preview_image_max_dimen); + // TODO: apparently this timeout is only used for not holding shared element transition + // animation for too long. If so, we already have a better place for it + // ChooserActivity$EnterTransitionAnimationDelegate. mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); ListenableFuture bitmapFuture = mBackgroundExecutor.submit( @@ -80,25 +70,22 @@ public class ChooserContentPreviewCoordinator implements @Override public void onSuccess(Bitmap loadedBitmap) { try { - onLoadCompleted( - deferredImageViewProvider.call(), - loadedBitmap, - extraImageCount); + callback.accept(loadedBitmap); + onLoadCompleted(loadedBitmap); } catch (Exception e) { /* unimportant */ } } @Override - public void onFailure(Throwable t) {} + public void onFailure(Throwable t) { + callback.accept(null); + } }, mHandler::post); } - private static final int IMAGE_FADE_IN_MILLIS = 150; - private final ChooserActivity mChooserActivity; private final ListeningExecutorService mBackgroundExecutor; private final Runnable mOnFailCallback; - private final Consumer mOnSingleImageSuccessCallback; private final int mImageLoadTimeoutMillis; // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a @@ -121,60 +108,25 @@ public class ChooserContentPreviewCoordinator implements } @MainThread - private void onLoadCompleted( - @Nullable RoundedRectImageView imageView, - @Nullable Bitmap loadedBitmap, - int extraImageCount) { + private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { if (mChooserActivity.isFinishing()) { return; } - // TODO: legacy logic didn't handle a possible null view; handle the same as other - // single-image failures for now (i.e., this is also a factor in the "race" TODO below). - boolean thisLoadSucceeded = (imageView != null) && (loadedBitmap != null); - mAtLeastOneLoaded |= thisLoadSucceeded; - - // TODO: this looks like a race condition. We may know that this specific image failed (i.e. - // it got a null Bitmap), but we'll only report that to the client (thereby failing out our - // pending loads) if we haven't yet succeeded in loading some other non-null Bitmap. But - // there could be other pending loads that would've returned non-null within the timeout - // window, except they end up (effectively) cancelled because this one single-image load - // "finished" (failed) faster. The outcome of that race may be fairly predictable (since we - // *might* imagine that the nulls would usually "load" faster?), but it's not guaranteed - // since the loads are queued in a thread pool (i.e., in parallel). One option for more - // deterministic behavior: don't signal the failure callback on a single-image load unless - // there are no other loads currently pending. + // TODO: the following logic can be described as "invoke the fail callback when the first + // image loading has failed". Historically, before we had switched from a single-threaded + // pool to a multi-threaded pool, we first loaded the transition element's image (the image + // preview is the only case when those callbacks matter) and aborting the animation on it's + // failure was reasonable. With the multi-thread pool, the first result may belong to any + // image and thus we can falsely abort the animation. + // Now, when we track the transition view state directly and after the timeout logic will + // be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of + // the fail callback and the following logic altogether. + mAtLeastOneLoaded |= loadedBitmap != null; boolean wholeBatchFailed = !mAtLeastOneLoaded; - if (thisLoadSucceeded) { - onImageLoadedSuccessfully(loadedBitmap, imageView, extraImageCount); - } else if (imageView != null) { - imageView.setVisibility(View.GONE); - } - if (wholeBatchFailed) { mOnFailCallback.run(); } } - - @MainThread - private void onImageLoadedSuccessfully( - @NonNull Bitmap image, - RoundedRectImageView imageView, - int extraImageCount) { - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - - if (extraImageCount > 0) { - imageView.setExtraImageCount(extraImageCount); - } - - mOnSingleImageSuccessCallback.accept(imageView); - } } diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 0cadce4b..ff88e5e1 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -18,12 +18,15 @@ package com.android.intentresolver; import static java.lang.annotation.RetentionPolicy.SOURCE; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.annotation.IntDef; import android.content.ClipData; import android.content.ContentResolver; import android.content.Intent; import android.content.res.Resources; import android.database.Cursor; +import android.graphics.Bitmap; import android.net.Uri; import android.provider.DocumentsContract; import android.provider.Downloads; @@ -35,6 +38,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; +import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; @@ -42,6 +46,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -50,7 +55,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; +import java.util.function.Consumer; /** * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. @@ -64,6 +69,8 @@ import java.util.concurrent.Callable; * as ivars when this "class" is initialized. */ public final class ChooserContentPreviewUi { + private static final int IMAGE_FADE_IN_MILLIS = 150; + /** * Delegate to handle background resource loads that are dependencies of content previews. */ @@ -71,19 +78,12 @@ public final class ChooserContentPreviewUi { /** * Request that an image be loaded in the background and set into a view. * - * @param viewProvider A delegate that will be called exactly once upon completion of the - * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be - * populated with the result (if the load was successful) or hidden (if the load failed). If - * this returns null, the load is discarded as a failure. * @param imageUri The {@link Uri} of the image to load. - * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} - * if the image loads successfully. * * TODO: it looks like clients are probably capable of passing the view directly, but the * deferred computation here is a closer match to the legacy model for now. */ - void loadUriIntoView( - Callable viewProvider, Uri imageUri, int extraImages); + void loadImage(Uri imageUri, Consumer callback); } /** @@ -182,6 +182,7 @@ public final class ChooserContentPreviewUi { @LayoutRes int actionRowLayout, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; @@ -203,6 +204,7 @@ public final class ChooserContentPreviewUi { createImagePreviewActions(actionFactory), parent, previewCoord, + onTransitionTargetReady, contentResolver, imageClassifier, actionRowLayout); @@ -290,11 +292,12 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), + previewCoord.loadImage( previewThumbnail, - 0); + (bitmap) -> updateViewWithImage( + contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + bitmap)); } } @@ -317,12 +320,13 @@ public final class ChooserContentPreviewUi { List actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ViewGroup imagePreview = contentPreviewLayout.findViewById( + ImagePreviewView imagePreview = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_image_area); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); @@ -330,60 +334,35 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } + final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); + final ArrayList imageUris = new ArrayList<>(); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { + // TODO: why don't we use image classifier in this case as well? Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - uri, - 0); + imageUris.add(uri); } else { List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - List imageUris = new ArrayList<>(); for (Uri uri : uris) { if (imageClassifier.isImageType(contentResolver.getType(uri))) { imageUris.add(uri); } } + } - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - return contentPreviewLayout; - } - - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - imageUris.get(0), - 0); - - if (imageUris.size() == 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_large), - imageUris.get(1), - 0); - } else if (imageUris.size() > 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_small), - imageUris.get(1), - 0); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_3_small), - imageUris.get(2), - imageUris.size() - 3); - } + if (imageUris.size() == 0) { + Log.i(TAG, "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + onTransitionTargetReady.accept(false); + return contentPreviewLayout; } + imagePreview.setSharedElementTransitionTarget( + ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, + onTransitionTargetReady); + imagePreview.setImages(imageUris, imageLoader); + return contentPreviewLayout; } @@ -503,11 +482,12 @@ public final class ChooserContentPreviewUi { fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - previewCoord.loadUriIntoView( - () -> parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), + previewCoord.loadImage( uri, - 0); + (bitmap) -> updateViewWithImage( + parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + bitmap)); } else { View thumbnailView = parent.findViewById( com.android.internal.R.id.content_preview_file_thumbnail); @@ -520,6 +500,21 @@ public final class ChooserContentPreviewUi { } } + private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { + if (image == null) { + imageView.setVisibility(View.GONE); + return; + } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + } + private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { String fileName = null; boolean hasThumbnail = false; diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt new file mode 100644 index 00000000..e68eb66a --- /dev/null +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import kotlinx.coroutines.suspendCancellableCoroutine + +// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. +internal class ImagePreviewImageLoader( + private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator +) : suspend (Uri) -> Bitmap? { + + override suspend fun invoke(uri: Uri): Bitmap? = + suspendCancellableCoroutine { continuation -> + val callback = java.util.function.Consumer { bitmap -> + try { + continuation.resumeWith(Result.success(bitmap)) + } catch (ignored: Exception) { + } + } + previewCoordinator.loadImage(uri, callback) + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt new file mode 100644 index 00000000..a37ef954 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2022 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.intentresolver.widget + +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewTreeObserver +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import com.android.intentresolver.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import java.util.function.Consumer +import com.android.internal.R as IntR + +typealias ImageLoader = suspend (Uri) -> Bitmap? + +private const val IMAGE_FADE_IN_MILLIS = 150L + +class ImagePreviewView : RelativeLayout { + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + private val coroutineScope = MainScope() + private lateinit var mainImage: RoundedRectImageView + private lateinit var secondLargeImage: RoundedRectImageView + private lateinit var secondSmallImage: RoundedRectImageView + private lateinit var thirdImage: RoundedRectImageView + + private var loadImageJob: Job? = null + private var onTransitionViewReadyCallback: Consumer? = null + + override fun onFinishInflate() { + LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) + mainImage = requireViewById(IntR.id.content_preview_image_1_large) + secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) + secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) + thirdImage = requireViewById(IntR.id.content_preview_image_3_small) + } + + /** + * Specifies a transition animation target name and a readiness callback. The callback will be + * invoked once when the view preparation is done i.e. either when an image is loaded into it + * and it is laid out (and it is ready to be draw) or image loading has failed. + * Should be called before [setImages]. + * @param name, transition name + * @param onViewReady, a callback that will be invoked with `true` if the view is ready to + * receive transition animation (the image was loaded successfully) and with `false` otherwise. + */ + fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer) { + mainImage.transitionName = name + onTransitionViewReadyCallback = onViewReady + } + + fun setImages(uris: List, imageLoader: ImageLoader) { + loadImageJob?.cancel() + loadImageJob = coroutineScope.launch { + when (uris.size) { + 0 -> hideAllViews() + 1 -> showOneImage(uris, imageLoader) + 2 -> showTwoImages(uris, imageLoader) + else -> showThreeImages(uris, imageLoader) + } + } + } + + private fun hideAllViews() { + mainImage.isVisible = false + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + + private suspend fun showOneImage(uris: List, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List, imageLoader: ImageLoader, vararg views: RoundedRectImageView + ) = coroutineScope { + for (i in views.indices) { + launch { + loadImageIntoView(views[i], uris[i], imageLoader) + } + } + } + + private suspend fun loadImageIntoView( + view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader + ) { + val bitmap = runCatching { + imageLoader(uri) + }.getOrDefault(null) + if (bitmap == null) { + view.isVisible = false + if (view === mainImage) { + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + } else { + view.isVisible = true + view.setImageBitmap(bitmap) + + view.alpha = 0f + ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { + interpolator = DecelerateInterpolator(1.0f) + duration = IMAGE_FADE_IN_MILLIS + start() + } + if (view === mainImage && onTransitionViewReadyCallback != null) { + setupPreDrawListener(mainImage) + } + } + } + + private fun setupPreDrawListener(view: View) { + view.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + view.viewTreeObserver.removeOnPreDrawListener(this) + invokeTransitionViewReadyCallback(runTransitionAnimation = true) + return true + } + } + ) + } + + private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { + onTransitionViewReadyCallback?.accept(runTransitionAnimation) + onTransitionViewReadyCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java index cf7bd543..8538041b 100644 --- a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -96,6 +96,7 @@ public class RoundedRectImageView extends ImageView { } else { this.mExtraImageCount = null; } + invalidate(); } @Override -- cgit v1.2.3-59-g8ed1b From f9a8f40c27f1b0188cbfa9b20af44ea04614cf93 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 28 Nov 2022 11:54:33 -0800 Subject: ResolverDrawerLayout: unify mCollapsibleHeight calculation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (an ag/20419209 cherry-pick) mCollapsibleHeight was calculated differently in two places: in onMeasure() and in setCollapsibleHeightReserved() methods. The latter is updated to use the same logic as the former. There was two particular aspects of the onMeasure() logic: - It calculated mCollapsibleHeight and mUncollapsibleHeight so the sum of the two yields the total children height; - mCollapsibleHeight was calculated based on the total childrens height. Thus a correct mCollapsibleHeight calculation in the setCollapsibleHeightReserved() method would need to know the total children height and maintain the invariant that mCollapsibleHeight + mUncollapsibleHeight would yield that height. Instead of deriving the total children height as a sum of mCollapsibleHeight + mUncollapsibleHeight, a new field, mHeightUsed, is added to store the total children height and mUncollapsibleHeight is deleted. This way we don’t have to also update mUncollapsibleHeight in setCollapsibleHeightReserved() plus mUncollapsibleHeight was always used as a part of the sum mCollapsibleHeight + mUncollapsibleHeight so it is a natural replacement. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 256869196 Test: reproduce the issue in a separate test environment, make sure that the change acutally fixes it Test: manual Chooser smoke test Test: manual Resolver smoke test Test: atest FrameworksCoreTests:ChooserActivityTest Test: atest FrameworksCoreTests:ResolverActivityTest Change-Id: Id169bd1c4b9f7270d75baab2e5e3841b76ab00e6 --- .../widget/ResolverDrawerLayout.java | 37 +++++++++++----------- .../UnbundledChooserActivityWorkProfileTest.java | 34 ++++++++++---------- 2 files changed, 34 insertions(+), 37 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index a2c5afc6..f5e20510 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -89,8 +89,8 @@ public class ResolverDrawerLayout extends ViewGroup { * out of sync due to frequently dropping fractions of a pixel from '(int) dy' casts. */ private float mDragRemainder = 0.0f; + private int mHeightUsed; private int mCollapsibleHeight; - private int mUncollapsibleHeight; private int mAlwaysShowHeight; /** @@ -244,9 +244,7 @@ public class ResolverDrawerLayout extends ViewGroup { mLastTouchY -= dReserved; } - final int oldCollapsibleHeight = mCollapsibleHeight; - mCollapsibleHeight = Math.min(mCollapsibleHeight, getMaxCollapsedHeight()); - + final int oldCollapsibleHeight = updateCollapsibleHeight(); if (updateCollapseOffset(oldCollapsibleHeight, !isDragging())) { return; } @@ -485,7 +483,7 @@ public class ResolverDrawerLayout extends ViewGroup { } else { if (isDismissable() && yvel > 0 && mCollapseOffset > mCollapsibleHeight) { - smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, yvel); + smoothScrollTo(mHeightUsed, yvel); mDismissOnScrollerFinished = true; } else { scrollNestedScrollableChildBackToTop(); @@ -575,8 +573,7 @@ public class ResolverDrawerLayout extends ViewGroup { return 0; } - final float newPos = Math.max(0, Math.min(mCollapseOffset + dy, - mCollapsibleHeight + mUncollapsibleHeight)); + final float newPos = Math.max(0, Math.min(mCollapseOffset + dy, mHeightUsed)); if (newPos != mCollapseOffset) { dy = newPos - mCollapseOffset; @@ -855,7 +852,7 @@ public class ResolverDrawerLayout extends ViewGroup { } else { if (isDismissable() && velocityY < 0 && mCollapseOffset > mCollapsibleHeight) { - smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, velocityY); + smoothScrollTo(mHeightUsed, velocityY); mDismissOnScrollerFinished = true; } else { smoothScrollTo(velocityY > 0 ? 0 : mCollapsibleHeight, velocityY); @@ -883,9 +880,8 @@ public class ResolverDrawerLayout extends ViewGroup { } break; case AccessibilityNodeInfo.ACTION_DISMISS: - if ((mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight) - && isDismissable()) { - smoothScrollTo(mCollapsibleHeight + mUncollapsibleHeight, 0); + if ((mCollapseOffset < mHeightUsed) && isDismissable()) { + smoothScrollTo(mHeightUsed, 0); mDismissOnScrollerFinished = true; return true; } @@ -923,7 +919,7 @@ public class ResolverDrawerLayout extends ViewGroup { info.addAction(AccessibilityAction.ACTION_SCROLL_DOWN); info.setScrollable(true); } - if ((mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight) + if ((mCollapseOffset < mHeightUsed) && ((mCollapseOffset < mCollapsibleHeight) || isDismissable())) { info.addAction(AccessibilityAction.ACTION_SCROLL_UP); info.setScrollable(true); @@ -931,7 +927,7 @@ public class ResolverDrawerLayout extends ViewGroup { if (mCollapseOffset < mCollapsibleHeight) { info.addAction(AccessibilityAction.ACTION_COLLAPSE); } - if (mCollapseOffset < mCollapsibleHeight + mUncollapsibleHeight && isDismissable()) { + if (mCollapseOffset < mHeightUsed && isDismissable()) { info.addAction(AccessibilityAction.ACTION_DISMISS); } } @@ -1022,22 +1018,25 @@ public class ResolverDrawerLayout extends ViewGroup { } } - final int oldCollapsibleHeight = mCollapsibleHeight; - mCollapsibleHeight = Math.max(0, - heightUsed - mAlwaysShowHeight - getMaxCollapsedHeight()); - mUncollapsibleHeight = heightUsed - mCollapsibleHeight; - + mHeightUsed = heightUsed; + int oldCollapsibleHeight = updateCollapsibleHeight(); updateCollapseOffset(oldCollapsibleHeight, !isDragging()); if (getShowAtTop()) { mTopOffset = 0; } else { - mTopOffset = Math.max(0, heightSize - heightUsed) + (int) mCollapseOffset; + mTopOffset = Math.max(0, heightSize - mHeightUsed) + (int) mCollapseOffset; } setMeasuredDimension(sourceWidth, heightSize); } + private int updateCollapsibleHeight() { + final int oldCollapsibleHeight = mCollapsibleHeight; + mCollapsibleHeight = Math.max(0, mHeightUsed - mAlwaysShowHeight - getMaxCollapsedHeight()); + return oldCollapsibleHeight; + } + /** * @return The space reserved by views with 'alwaysShow=true' */ diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index b7eecb3f..ca2e8ccd 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -24,6 +24,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; @@ -31,7 +32,6 @@ import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; -import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; import static org.hamcrest.CoreMatchers.not; import static org.mockito.ArgumentMatchers.eq; @@ -45,9 +45,9 @@ import androidx.test.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; -import com.android.internal.R; import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; +import com.android.internal.R; import org.junit.Before; import org.junit.Rule; @@ -129,14 +129,13 @@ public class UnbundledChooserActivityWorkProfileTest { /* tab= */ WORK, /* expectedBlocker= */ NO_BLOCKER ), -// TODO(b/256869196) ChooserActivity goes into requestLayout loop -// new TestCase( -// /* isSendAction= */ true, -// /* hasCrossProfileIntents= */ false, -// /* myUserHandle= */ WORK_USER_HANDLE, -// /* tab= */ WORK, -// /* expectedBlocker= */ NO_BLOCKER -// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ WORK, + /* expectedBlocker= */ NO_BLOCKER + ), new TestCase( /* isSendAction= */ true, /* hasCrossProfileIntents= */ true, @@ -158,14 +157,13 @@ public class UnbundledChooserActivityWorkProfileTest { /* tab= */ PERSONAL, /* expectedBlocker= */ NO_BLOCKER ), -// TODO(b/256869196) ChooserActivity goes into requestLayout loop -// new TestCase( -// /* isSendAction= */ true, -// /* hasCrossProfileIntents= */ false, -// /* myUserHandle= */ WORK_USER_HANDLE, -// /* tab= */ PERSONAL, -// /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER -// ), + new TestCase( + /* isSendAction= */ true, + /* hasCrossProfileIntents= */ false, + /* myUserHandle= */ WORK_USER_HANDLE, + /* tab= */ PERSONAL, + /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER + ), new TestCase( /* isSendAction= */ true, /* hasCrossProfileIntents= */ true, -- cgit v1.2.3-59-g8ed1b From 54f453056eb6be1613bf474ecb5b95346105c767 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 29 Dec 2022 09:18:38 -0800 Subject: Move EnterTransitionAnimationDelegate out of ChooserActivity Movre reamining of the shared element transition logic from ChooserActivity into EnterTransitionAnimationDelegate, the delegate is moved into the package level and converted to Kotlin. Patchsets: 1: Consolidate the logic in the delegate and move the delegate into the package level. 2: Automatic EnterTransitionAnimationDelegate conversion from Java to Kotlin. 3+: Manual Kotlin code adjustment. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual basic functionality test Test: atest IntentResolverUnitTests Change-Id: Ia56e92ed5358ca66185f5011abd139392ee73785 --- .../android/intentresolver/ChooserActivity.java | 82 +--------------- .../ChooserContentPreviewCoordinator.java | 2 +- .../EnterTransitionAnimationDelegate.kt | 107 +++++++++++++++++++++ 3 files changed, 112 insertions(+), 79 deletions(-) create mode 100644 java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index df71c7ff..ceab62b2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -32,7 +32,6 @@ import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; -import android.app.SharedElementCallback; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -249,9 +248,7 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = - new EnterTransitionAnimationDelegate(); - - private boolean mRemoveSharedElements = false; + new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); private View mContentView = null; @@ -259,21 +256,6 @@ public class ChooserActivity extends ResolverActivity implements public ChooserActivity() {} - private void onSharedElementTransitionTargetReady(boolean runTransitionAnimation) { - if (runTransitionAnimation && !mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the transition animation. - getWindow().setWindowAnimations(0); - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - } else { - onSharedElementTransitionTargetMissing(); - } - } - - private void onSharedElementTransitionTargetMissing() { - mRemoveSharedElements = true; - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - } - @Override protected void onCreate(Bundle savedInstanceState) { final long intentReceivedTime = System.currentTimeMillis(); @@ -312,7 +294,7 @@ public class ChooserActivity extends ResolverActivity implements mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, this, - this::onSharedElementTransitionTargetMissing); + () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false)); super.onCreate( savedInstanceState, @@ -371,17 +353,6 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetAction() ); - setEnterSharedElementCallback(new SharedElementCallback() { - @Override - public void onMapSharedElements(List names, Map sharedElements) { - if (mRemoveSharedElements) { - names.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - sharedElements.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - } - super.onMapSharedElements(names, sharedElements); - mRemoveSharedElements = false; - } - }); mEnterTransitionAnimationDelegate.postponeTransition(); } @@ -772,7 +743,7 @@ public class ChooserActivity extends ResolverActivity implements R.layout.chooser_action_row, parent, previewCoordinator, - this::onSharedElementTransitionTargetReady, + mEnterTransitionAnimationDelegate::markImagePreviewReady, getContentResolver(), this::isImageType); @@ -780,7 +751,7 @@ public class ChooserActivity extends ResolverActivity implements adjustPreviewWidth(getResources().getConfiguration().orientation, layout); } if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { - mEnterTransitionAnimationDelegate.markImagePreviewReady(); + mEnterTransitionAnimationDelegate.markImagePreviewReady(false); } return layout; @@ -2190,51 +2161,6 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * A helper class to track app's readiness for the scene transition animation. - * The app is ready when both the image is laid out and the drawer offset is calculated. - */ - private class EnterTransitionAnimationDelegate implements View.OnLayoutChangeListener { - private boolean mPreviewReady = false; - private boolean mOffsetCalculated = false; - - void postponeTransition() { - postponeEnterTransition(); - } - - void markImagePreviewReady() { - if (!mPreviewReady) { - mPreviewReady = true; - maybeStartListenForLayout(); - } - } - - void markOffsetCalculated() { - if (!mOffsetCalculated) { - mOffsetCalculated = true; - maybeStartListenForLayout(); - } - } - - private void maybeStartListenForLayout() { - if (mPreviewReady && mOffsetCalculated && mResolverDrawerLayout != null) { - if (mResolverDrawerLayout.isInLayout()) { - startPostponedEnterTransition(); - } else { - mResolverDrawerLayout.addOnLayoutChangeListener(this); - mResolverDrawerLayout.requestLayout(); - } - } - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, - int oldTop, int oldRight, int oldBottom) { - v.removeOnLayoutChangeListener(this); - startPostponedEnterTransition(); - } - } - /** * Used in combination with the scene transition when launching the image editor */ diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java index 93552e31..0b8dbe35 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -58,7 +58,7 @@ public class ChooserContentPreviewCoordinator implements // TODO: apparently this timeout is only used for not holding shared element transition // animation for too long. If so, we already have a better place for it - // ChooserActivity$EnterTransitionAnimationDelegate. + // EnterTransitionAnimationDelegate. mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); ListenableFuture bitmapFuture = mBackgroundExecutor.submit( diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt new file mode 100644 index 00000000..a0bf61b6 --- /dev/null +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.app.Activity +import android.app.SharedElementCallback +import android.view.View +import com.android.intentresolver.widget.ResolverDrawerLayout +import java.util.function.Supplier + +/** + * A helper class to track app's readiness for the scene transition animation. + * The app is ready when both the image is laid out and the drawer offset is calculated. + */ +internal class EnterTransitionAnimationDelegate( + private val activity: Activity, + private val resolverDrawerLayoutSupplier: Supplier +) : View.OnLayoutChangeListener { + private var removeSharedElements = false + private var previewReady = false + private var offsetCalculated = false + + init { + activity.setEnterSharedElementCallback( + object : SharedElementCallback() { + override fun onMapSharedElements( + names: MutableList, sharedElements: MutableMap + ) { + this@EnterTransitionAnimationDelegate.onMapSharedElements( + names, sharedElements + ) + } + }) + } + + fun postponeTransition() = activity.postponeEnterTransition() + + fun markImagePreviewReady(runTransitionAnimation: Boolean) { + if (!runTransitionAnimation) { + removeSharedElements = true + } + if (!previewReady) { + previewReady = true + maybeStartListenForLayout() + } + } + + fun markOffsetCalculated() { + if (!offsetCalculated) { + offsetCalculated = true + maybeStartListenForLayout() + } + } + + private fun onMapSharedElements( + names: MutableList, + sharedElements: MutableMap + ) { + if (removeSharedElements) { + names.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) + sharedElements.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) + } + removeSharedElements = false + } + + private fun maybeStartListenForLayout() { + val drawer = resolverDrawerLayoutSupplier.get() + if (previewReady && offsetCalculated && drawer != null) { + if (drawer.isInLayout) { + startPostponedEnterTransition() + } else { + drawer.addOnLayoutChangeListener(this) + drawer.requestLayout() + } + } + } + + override fun onLayoutChange( + v: View, + left: Int, top: Int, right: Int, bottom: Int, + oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + ) { + v.removeOnLayoutChangeListener(this) + startPostponedEnterTransition() + } + + private fun startPostponedEnterTransition() { + if (!removeSharedElements && activity.isActivityTransitionRunning) { + // Disable the window animations as it interferes with the transition animation. + activity.window.setWindowAnimations(0) + } + activity.startPostponedEnterTransition() + } +} -- cgit v1.2.3-59-g8ed1b