diff options
author | 2023-09-12 14:20:36 -0700 | |
---|---|---|
committer | 2023-09-18 09:59:57 -0700 | |
commit | 1c0f4edb1114be0b7b994d6d7a7e3c6f0c5215d0 (patch) | |
tree | 5c3e2ea23f153b49c227a0a1bac138e67fe3d16b | |
parent | 41e6f25694939bb4c88485d0513e413da772931b (diff) |
Add ResolverListAdapter unit tests
Replace AsyncTask usage with an background executor and posting on a
main thread hanler to facilitate testing.
Tests are mostly around targets resolution logic in the adapter.
Test: atest IntentResolverUnitTests:ResolverListAdapterTest
Change-Id: I7af047226aa718ca3052aa4284d1e9d2a4c43ded
8 files changed, 938 insertions, 70 deletions
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index d1101f1e..a9ed983d 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -43,6 +43,9 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; @@ -567,7 +570,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator.resolveInfoMatch( + if (ResolveInfoHelpers.resolveInfoMatch( dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -658,30 +661,23 @@ public class ChooserListAdapter extends ResolverListAdapter { * in the head of input list and fill the tail with other elements in undetermined order. */ @Override - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], mMaxRankedTargets); - Trace.endSection(); - return params[0]; - } - - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - notifyDataSetChanged(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + Trace.beginSection("ChooserListAdapter#SortingTask"); + mResolverListController.topK(components, mMaxRankedTargets); + Trace.endSection(); } + @Override + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + //TODO: this method is different from super's only in that `notifyDataSetChanged` is + // called conditionally here; is it really important? + notifyDataSetChanged(); + } + } } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 0d3becc2..47a8bf2a 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -2262,20 +2262,6 @@ public class ResolverActivity extends FragmentActivity implements mRetainInOnStop = retainInOnStop; } - /** - * Check a simple match for the component of two ResolveInfos. - */ - @Override // ResolverListCommunicator - public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { - return lhs == null ? rhs == null - : lhs.activityInfo == null ? rhs.activityInfo == null - : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) - && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) - // Comparing against resolveInfo.userHandle in case cloned apps are present, - // as they will have the same activityInfo. - && Objects.equals(lhs.userHandle, rhs.userHandle); - } - private boolean inactiveListAdapterHasItems() { if (!shouldShowTabs()) { return false; diff --git a/java/src/com/android/intentresolver/ResolverInfoHelpers.kt b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt new file mode 100644 index 00000000..8d1d8658 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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. + */ + +@file:JvmName("ResolveInfoHelpers") + +package com.android.intentresolver + +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo + +fun resolveInfoMatch(lhs: ResolveInfo?, rhs: ResolveInfo?): Boolean = + (lhs === rhs) || + ((lhs != null && rhs != null) && + activityInfoMatch(lhs.activityInfo, rhs.activityInfo) && + // Comparing against resolveInfo.userHandle in case cloned apps are present, + // as they will have the same activityInfo. + lhs.userHandle == rhs.userHandle) + +private fun activityInfoMatch(lhs: ActivityInfo?, rhs: ActivityInfo?): Boolean = + (lhs === rhs) || + (lhs != null && rhs != null && lhs.name == rhs.name && lhs.packageName == rhs.packageName) diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 282a672f..14ce0e0e 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -28,6 +28,7 @@ import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; import android.os.AsyncTask; +import android.os.Handler; import android.os.RemoteException; import android.os.Trace; import android.os.UserHandle; @@ -42,6 +43,9 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; @@ -53,6 +57,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Executor; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -75,6 +80,8 @@ public class ResolverListAdapter extends BaseAdapter { private final Set<DisplayResolveInfo> mRequestedIcons = new HashSet<>(); private final Set<DisplayResolveInfo> mRequestedLabels = new HashSet<>(); + private final Executor mBgExecutor; + private final Handler mMainHandler; private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; @@ -103,6 +110,37 @@ public class ResolverListAdapter extends BaseAdapter { ResolverListCommunicator resolverListCommunicator, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + initialIntentsUserSpace, + targetDataLoader, + AsyncTask.SERIAL_EXECUTOR, + context.getMainThreadHandler()); + } + + @VisibleForTesting + public ResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + Executor bgExecutor, + Handler mainHandler) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -117,6 +155,8 @@ public class ResolverListAdapter extends BaseAdapter { mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mInitialIntentsUserSpace = initialIntentsUserSpace; + mBgExecutor = bgExecutor; + mMainHandler = mainHandler; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -402,35 +442,42 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); - createSortingTask(doPostProcessing).execute(filteredResolveList); + mBgExecutor.execute(() -> { + List<ResolvedComponentInfo> sortedComponents = null; + //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. + // Empirically, we don't need it as in the case on an exception, the app will crash and + // `onComponentsSorted` won't be invoked. + try { + sortComponents(filteredResolveList); + sortedComponents = filteredResolveList; + } catch (Throwable t) { + Log.e(TAG, "Failed to sort components", t); + throw t; + } finally { + final List<ResolvedComponentInfo> result = sortedComponents; + mMainHandler.post(() -> onComponentsSorted(result, doPostProcessing)); + } + }); return false; } - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - mResolverListController.sort(params[0]); - return params[0]; - } - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + mResolverListController.sort(components); } - protected void processSortedList(List<ResolvedComponentInfo> sortedComponents, - boolean doPostProcessing) { + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + notifyDataSetChanged(); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + } + } + + protected void processSortedList( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { final int n = sortedComponents != null ? sortedComponents.size() : 0; Trace.beginSection("ResolverListAdapter#processSortedList:" + n); if (n != 0) { @@ -509,7 +556,7 @@ public class ResolverListAdapter extends BaseAdapter { mPostListReadyRunnable = null; } }; - mContext.getMainThreadHandler().post(mPostListReadyRunnable); + mMainHandler.post(mPostListReadyRunnable); } } @@ -572,7 +619,7 @@ public class ResolverListAdapter extends BaseAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in display. for (DisplayResolveInfo existingInfo : mDisplayList) { - if (mResolverListCommunicator + if (ResolveInfoHelpers .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -728,7 +775,7 @@ public class ResolverListAdapter extends BaseAdapter { public void onDestroy() { if (mPostListReadyRunnable != null) { - mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable); + mMainHandler.removeCallbacks(mPostListReadyRunnable); mPostListReadyRunnable = null; } if (mResolverListController != null) { @@ -856,8 +903,6 @@ public class ResolverListAdapter extends BaseAdapter { */ interface ResolverListCommunicator { - boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs); - Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index d5a5fedf..cb56ab30 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -254,7 +254,6 @@ public class ResolverListController { isComputed = true; } - @VisibleForTesting @WorkerThread public void sort(List<ResolvedComponentInfo> inputList) { try { @@ -273,7 +272,6 @@ public class ResolverListController { } } - @VisibleForTesting @WorkerThread public void topK(List<ResolvedComponentInfo> inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { diff --git a/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt new file mode 100644 index 00000000..a5fe6c47 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -0,0 +1,727 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.ResolveInfo +import android.os.UserHandle +import android.view.LayoutInflater +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.util.TestExecutor +import com.android.intentresolver.util.TestImmediateHandler +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Test +import org.mockito.Mockito.anyBoolean + +private const val PKG_NAME = "org.pkg.app" +private const val PKG_NAME_TWO = "org.pkgtwo.app" +private const val CLASS_NAME = "org.pkg.app.TheClass" + +class ResolverListAdapterTest { + private val testHandler = TestImmediateHandler() + private val layoutInflater = mock<LayoutInflater>() + private val context = + mock<Context> { + whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) + whenever(mainThreadHandler).thenReturn(testHandler) + } + private val targetIntent = Intent(Intent.ACTION_SEND) + private val payloadIntents = listOf(targetIntent) + private val resolverListController = + mock<ResolverListController> { + whenever(filterIneligibleActivities(any(), anyBoolean())).thenReturn(null) + whenever(filterLowPriority(any(), anyBoolean())).thenReturn(null) + } + private val resolverListCommunicator = FakeResolverListCommunicator() + private val userHandle = UserHandle.of(0) + private val targetDataLoader = mock<TargetDataLoader>() + private val executor = TestExecutor() + + @Test + fun test_oneTargetNoLastChosen_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + } + + @Test + fun test_oneTargetThatWasLastChosen_NoTargetsInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(0) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNotNull() + assertThat(testSubject.filteredPosition).isEqualTo(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_oneTargetLastChosenNotInTheList_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(createResolveInfo(PKG_NAME_TWO, CLASS_NAME)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_oneTargetThatWasLastChosenFilteringDisabled_oneTargetInAdapter() { + val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ false, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + // we don't reset placeholder count + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + } + + @Test + fun test_twoTargetsNoLastChosenUseLayoutWithDefaults_twoTargetsInAdapter() { + testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = true) + } + + @Test + fun test_twoTargetsNoLastChosenDontUseLayoutWithDefaults_twoTargetsInAdapter() { + testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = false) + } + + @Test + fun test_twoTargetsLastChosenUseLayoutWithDefaults_oneTargetInAdapter() { + testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = true) + } + + @Test + fun test_twoTargetsLastChosenDontUseLayoutWithDefaults_oneTargetInAdapter() { + testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = false) + } + + private fun testTwoTargets(hasLastChosen: Boolean, useLayoutWithDefaults: Boolean) { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + if (hasLastChosen) { + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + } + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val resolverListCommunicator = FakeResolverListCommunicator(useLayoutWithDefaults) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - (if (useLayoutWithDefaults) 1 else 0) + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen) + if (hasLastChosen) { + assertThat(testSubject.count).isEqualTo(resolvedTargets.size - 1) + assertThat(testSubject.filteredItem).isNotNull() + assertThat(testSubject.filteredPosition).isEqualTo(0) + } else { + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + } + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) + assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_twoTargetsLastChosenNotInTheList_twoTargetsInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever(resolverListController.lastChosen) + .thenReturn(createResolveInfo(PKG_NAME, CLASS_NAME + "2")) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = false + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isFalse() + val placeholderCount = resolvedTargets.size - 1 + assertThat(testSubject.count).isEqualTo(placeholderCount) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isFalse() + assertThat(executor.pendingCommandCount).isEqualTo(1) + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount) + assertThat(testSubject.hasFilteredItem()).isTrue() + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Test + fun test_twoTargetsWithOtherProfileAndLastChosen_oneTargetInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + resolvedTargets[1].getResolveInfoAt(0).targetUserId = 10 + whenever(resolvedTargets[1].getResolveInfoAt(0).loadLabel(any())).thenReturn("Label") + whenever(resolverListController.lastChosen) + .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + val isLoaded = testSubject.rebuildList(doPostProcessing) + + assertThat(isLoaded).isTrue() + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.placeholderCount).isEqualTo(0) + assertThat(testSubject.otherProfile).isNotNull() + assertThat(testSubject.hasFilteredItem()).isFalse() + assertThat(testSubject.filteredItem).isNull() + assertThat(testSubject.filteredPosition).isLessThan(0) + assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertThat(testSubject.isTabLoaded).isTrue() + assertThat(executor.pendingCommandCount).isEqualTo(0) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_resultsSorted_appearInSortedOrderInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.sort(any())).thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> + components[0] = components[1].also { components[1] = components[0] } + null + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(resolvedTargets.size) + assertThat(resolvedTargets[0].getResolveInfoAt(0).activityInfo.packageName) + .isEqualTo(PKG_NAME_TWO) + assertThat(resolvedTargets[1].getResolveInfoAt(0).activityInfo.packageName) + .isEqualTo(PKG_NAME) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_ineligibleActivityFilteredOut_filteredComponentNotPresentInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) + .thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.removeAt(1) + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.getItem(0)?.resolveInfo) + .isEqualTo(resolvedTargets[0].getResolveInfoAt(0)) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_baseResolveList_excludedFromIneligibleActivityFiltering() { + val rList = listOf(createResolveInfo(PKG_NAME, CLASS_NAME)) + whenever(resolverListController.addResolveListDedupe(any(), eq(targetIntent), eq(rList))) + .thenAnswer { invocation -> + val result = invocation.arguments[0] as MutableList<ResolvedComponentInfo> + result.addAll( + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + ) + null + } + whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) + .thenAnswer { invocation -> + val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.clear() + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + rList, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(2) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun test_lowPriorityComponentFilteredOut_filteredComponentNotPresentInAdapter() { + val resolvedTargets = + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + whenever( + resolverListController.getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + ) + .thenReturn(resolvedTargets) + whenever(resolverListController.filterLowPriority(any(), anyBoolean())).thenAnswer { + invocation -> + val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.removeAt(1) + original + } + val testSubject = + ResolverListAdapter( + context, + payloadIntents, + /*initialIntents=*/ null, + /*rList=*/ null, + /*filterLastUsed=*/ true, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + /*initialIntentsUserSpace=*/ userHandle, + targetDataLoader, + executor, + testHandler, + ) + val doPostProcessing = true + + testSubject.rebuildList(doPostProcessing) + + executor.runUntilIdle() + + // we don't reset placeholder count (legacy logic, likely an oversight?) + assertThat(testSubject.count).isEqualTo(1) + assertThat(testSubject.getItem(0)?.resolveInfo) + .isEqualTo(resolvedTargets[0].getResolveInfoAt(0)) + assertThat(testSubject.unfilteredResolveList).hasSize(2) + } + + private fun createResolvedComponents( + vararg components: ComponentName + ): List<ResolvedComponentInfo> { + val result = ArrayList<ResolvedComponentInfo>(components.size) + for (component in components) { + val resolvedComponentInfo = + ResolvedComponentInfo( + ComponentName(PKG_NAME, CLASS_NAME), + targetIntent, + createResolveInfo(component.packageName, component.className) + ) + result.add(resolvedComponentInfo) + } + return result + } + + private fun createResolveInfo(packageName: String, className: String): ResolveInfo = + mock<ResolveInfo> { + activityInfo = + ActivityInfo().apply { + name = className + this.packageName = packageName + applicationInfo = ApplicationInfo().apply { this.packageName = packageName } + } + targetUserId = UserHandle.USER_CURRENT + } +} + +private class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = true) : + ResolverListAdapter.ResolverListCommunicator { + private val sendVoiceCounter = AtomicInteger() + private val updateProfileViewButtonCounter = AtomicInteger() + + val sendVoiceCommandCount + get() = sendVoiceCounter.get() + val updateProfileViewButtonCount + get() = updateProfileViewButtonCounter.get() + + override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { + return defIntent + } + + override fun onPostListReady( + listAdapter: ResolverListAdapter?, + updateUi: Boolean, + rebuildCompleted: Boolean, + ) = Unit + + override fun sendVoiceChoicesIfNeeded() { + sendVoiceCounter.incrementAndGet() + } + + override fun updateProfileViewButton() { + updateProfileViewButtonCounter.incrementAndGet() + } + + override fun useLayoutWithDefault(): Boolean = layoutWithDefaults + + override fun shouldGetActivityMetadata(): Boolean = true + + override fun onHandlePackagesChanged(listAdapter: ResolverListAdapter?) {} +} diff --git a/java/tests/src/com/android/intentresolver/util/TestExecutor.kt b/java/tests/src/com/android/intentresolver/util/TestExecutor.kt new file mode 100644 index 00000000..214b9707 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/util/TestExecutor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 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.util + +import java.util.concurrent.Executor + +class TestExecutor(private val immediate: Boolean = false) : Executor { + private var pendingCommands = ArrayDeque<Runnable>() + + val pendingCommandCount: Int + get() = pendingCommands.size + + override fun execute(command: Runnable) { + if (immediate) { + command.run() + } else { + pendingCommands.add(command) + } + } + + fun runUntilIdle() { + while (pendingCommands.isNotEmpty()) { + pendingCommands.removeFirst().run() + } + } +} diff --git a/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt b/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt new file mode 100644 index 00000000..9e6fc989 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/util/TestImmediateHandler.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 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.util + +import android.os.Handler +import android.os.Looper +import android.os.Message + +/** + * A test Handler that executes posted [Runnable] immediately regardless of the target time (delay). + * Does not support messages. + */ +class TestImmediateHandler : Handler(createTestLooper()) { + override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { + msg.callback.run() + return true + } + + companion object { + private val looperConstructor by lazy { + Looper::class.java.getDeclaredConstructor(java.lang.Boolean.TYPE).apply { + isAccessible = true + } + } + + private fun createTestLooper(): Looper = looperConstructor.newInstance(true) + } +} |