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') 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