Loading packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java +92 −44 Original line number Diff line number Diff line Loading @@ -28,9 +28,9 @@ import android.app.TaskInfo; import android.app.WindowConfiguration; import android.app.assist.AssistContent; import android.content.ClipData; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; Loading Loading @@ -249,7 +249,6 @@ final class AppClipsViewModel extends ViewModel { .map(taskInfo -> new InternalTaskInfo(taskInfo.topActivityInfo, taskInfo.taskId, taskInfo.userId, getPackageManagerForUser(taskInfo.userId))) .filter(this::canAppStartThroughLauncher) .map(this::getBacklinksDataForTaskInfo) .toList(), mBgExecutor); Loading @@ -257,6 +256,17 @@ final class AppClipsViewModel extends ViewModel { return Futures.transformAsync(backlinksNestedListFuture, Futures::allAsList, mBgExecutor); } private PackageManager getPackageManagerForUser(int userId) { // If app clips was launched as the same user, then reuse the available PM from mContext. if (mContext.getUserId() == userId) { return mContext.getPackageManager(); } // PackageManager required for a different user, create its context and return its PM. UserHandle userHandle = UserHandle.of(userId); return mContext.createContextAsUser(userHandle, /* flags= */ 0).getPackageManager(); } /** * Returns all tasks on a given display after querying {@link IActivityTaskManager} from the * {@link #mBgExecutor}. Loading Loading @@ -310,17 +320,6 @@ final class AppClipsViewModel extends ViewModel { && taskInfo.getActivityType() == WindowConfiguration.ACTIVITY_TYPE_STANDARD; } /** * Returns whether the app represented by the {@link InternalTaskInfo} can be launched through * the all apps tray by a user. */ private boolean canAppStartThroughLauncher(InternalTaskInfo internalTaskInfo) { // Use Intent.resolveActivity API to check if the intent resolves as that is what Android // uses internally when apps use Context.startActivity. return getMainLauncherIntentForTask(internalTaskInfo) .resolveActivity(internalTaskInfo.getPackageManager()) != null; } /** * Returns an {@link InternalBacklinksData} that represents the Backlink data internally, which * is captured by querying the system using {@link TaskInfo#taskId}. Loading Loading @@ -390,11 +389,14 @@ final class AppClipsViewModel extends ViewModel { internalTaskInfo.getTaskId(), internalTaskInfo.getTopActivityNameForDebugLogging())); String appName = internalTaskInfo.getTopActivityAppName(); Drawable appIcon = internalTaskInfo.getTopActivityAppIcon(); ClipData mainLauncherIntent = ClipData.newIntent(appName, getMainLauncherIntentForTask(internalTaskInfo)); InternalBacklinksData fallback = new BacklinksData(mainLauncherIntent, appIcon); String screenshottedAppName = internalTaskInfo.getTopActivityAppName(); Drawable screenshottedAppIcon = internalTaskInfo.getTopActivityAppIcon(); Intent screenshottedAppMainLauncherIntent = getMainLauncherIntentForTask( internalTaskInfo.getTopActivityPackageName(), internalTaskInfo.getPackageManager()); ClipData screenshottedAppMainLauncherClipData = ClipData.newIntent(screenshottedAppName, screenshottedAppMainLauncherIntent); InternalBacklinksData fallback = new BacklinksData(screenshottedAppMainLauncherClipData, screenshottedAppIcon); if (content == null) { return fallback; } Loading @@ -406,10 +408,14 @@ final class AppClipsViewModel extends ViewModel { Uri uri = content.getWebUri(); Intent backlinksIntent = new Intent(ACTION_VIEW).setData(uri); if (doesIntentResolveToSameTask(backlinksIntent, internalTaskInfo)) { BacklinkDisplayInfo backlinkDisplayInfo = getInfoThatResolvesIntent(backlinksIntent, internalTaskInfo); if (backlinkDisplayInfo != null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getBacklinksDataFromAssistContent: using app provided uri"); return new BacklinksData(ClipData.newRawUri(appName, uri), appIcon); return new BacklinksData( ClipData.newRawUri(backlinkDisplayInfo.getDisplayLabel(), uri), backlinkDisplayInfo.getAppIcon()); } } Loading @@ -419,10 +425,14 @@ final class AppClipsViewModel extends ViewModel { () -> "getBacklinksDataFromAssistContent: app has provided an intent"); Intent backlinksIntent = content.getIntent(); if (doesIntentResolveToSameTask(backlinksIntent, internalTaskInfo)) { BacklinkDisplayInfo backlinkDisplayInfo = getInfoThatResolvesIntent(backlinksIntent, internalTaskInfo); if (backlinkDisplayInfo != null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getBacklinksDataFromAssistContent: using app provided intent"); return new BacklinksData(ClipData.newIntent(appName, backlinksIntent), appIcon); return new BacklinksData( ClipData.newIntent(backlinkDisplayInfo.getDisplayLabel(), backlinksIntent), backlinkDisplayInfo.getAppIcon()); } } Loading @@ -431,27 +441,76 @@ final class AppClipsViewModel extends ViewModel { return fallback; } private boolean doesIntentResolveToSameTask(Intent intentToResolve, InternalTaskInfo requiredTaskInfo) { PackageManager packageManager = requiredTaskInfo.getPackageManager(); ComponentName resolvedComponent = intentToResolve.resolveActivity(packageManager); if (resolvedComponent == null) { return false; /** * Returns {@link BacklinkDisplayInfo} for the app that would resolve the provided backlink * {@link Intent}. * * <p>The method uses the {@link PackageManager} available in the provided * {@link InternalTaskInfo}. * * <p>This method returns {@code null} if Android is not able to resolve the backlink intent or * if the resolved app does not have an icon in the launcher. */ @Nullable private BacklinkDisplayInfo getInfoThatResolvesIntent(Intent backlinkIntent, InternalTaskInfo internalTaskInfo) { PackageManager packageManager = internalTaskInfo.getPackageManager(); // Query for all available activities as there is a chance that multiple apps could resolve // the intent. In such cases the normal `intent.resolveActivity` API returns the activity // resolver info which isn't helpful for further checks. Also, using MATCH_DEFAULT_ONLY flag // is required as that flag will be used when the notes app builds the intent and calls // startActivity with the intent. List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(backlinkIntent, PackageManager.MATCH_DEFAULT_ONLY); if (resolveInfos.isEmpty()) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: could not resolve backlink intent"); return null; } // Only use the first result as the list is ordered from best match to worst and Android // will also use the best match with `intent.startActivity` API which notes app will use. ActivityInfo activityInfo = resolveInfos.get(0).activityInfo; if (activityInfo == null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: could not find activity info for backlink " + "intent"); return null; } // Ignore resolved backlink app if users cannot start it through all apps tray. if (!canAppStartThroughLauncher(activityInfo.packageName, packageManager)) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: ignoring resolved backlink app as it cannot" + " start through launcher"); return null; } String requiredPackageName = requiredTaskInfo.getTopActivityPackageName(); return resolvedComponent.getPackageName().equals(requiredPackageName); Drawable appIcon = InternalBacklinksDataKt.getAppIcon(activityInfo, packageManager); String appName = InternalBacklinksDataKt.getAppName(activityInfo, packageManager); return new BacklinkDisplayInfo(appIcon, appName); } /** * Returns whether the app represented by the provided {@code pkgName} can be launched through * the all apps tray by the user. */ private static boolean canAppStartThroughLauncher(String pkgName, PackageManager pkgManager) { // Use Intent.resolveActivity API to check if the intent resolves as that is what Android // uses internally when apps use Context.startActivity. return getMainLauncherIntentForTask(pkgName, pkgManager) .resolveActivity(pkgManager) != null; } private Intent getMainLauncherIntentForTask(InternalTaskInfo internalTaskInfo) { String pkgName = internalTaskInfo.getTopActivityPackageName(); private static Intent getMainLauncherIntentForTask(String pkgName, PackageManager packageManager) { Intent intent = new Intent(ACTION_MAIN).addCategory(CATEGORY_LAUNCHER).setPackage(pkgName); // Not all apps use DEFAULT_CATEGORY for their main launcher activity so the exact component // needs to be queried and set on the Intent in order for note-taking apps to be able to // start this intent. When starting an activity with an implicit intent, Android adds the // DEFAULT_CATEGORY flag otherwise it fails to resolve the intent. PackageManager packageManager = internalTaskInfo.getPackageManager(); ResolveInfo resolvedActivity = packageManager.resolveActivity(intent, /* flags= */ 0); if (resolvedActivity != null) { intent.setComponent(resolvedActivity.getComponentInfo().getComponentName()); Loading @@ -460,17 +519,6 @@ final class AppClipsViewModel extends ViewModel { return intent; } private PackageManager getPackageManagerForUser(int userId) { // If app clips was launched as the same user, then reuse the available PM from mContext. if (mContext.getUserId() == userId) { return mContext.getPackageManager(); } // PackageManager required for a different user, create its context and return its PM. UserHandle userHandle = UserHandle.of(userId); return mContext.createContextAsUser(userHandle, /* flags= */ 0).getPackageManager(); } /** Helper factory to help with injecting {@link AppClipsViewModel}. */ static final class Factory implements ViewModelProvider.Factory { Loading packages/SystemUI/src/com/android/systemui/screenshot/appclips/InternalBacklinksData.kt +29 −13 Original line number Diff line number Diff line Loading @@ -27,16 +27,27 @@ import android.graphics.drawable.Drawable * represent error when necessary. */ internal sealed class InternalBacklinksData( open val appIcon: Drawable, open var displayLabel: String // Fields from this object are made accessible through accessors to keep call sites simpler. private val backlinkDisplayInfo: BacklinkDisplayInfo, ) { data class BacklinksData(val clipData: ClipData, override val appIcon: Drawable) : InternalBacklinksData(appIcon, clipData.description.label.toString()) // Use separate field to access display label so that callers don't have to access through // internal object. var displayLabel: String get() = backlinkDisplayInfo.displayLabel set(value) { backlinkDisplayInfo.displayLabel = value } // Use separate field to access app icon so that callers don't have to access through internal // object. val appIcon: Drawable get() = backlinkDisplayInfo.appIcon data class BacklinksData(val clipData: ClipData, private val icon: Drawable) : InternalBacklinksData(BacklinkDisplayInfo(icon, clipData.description.label.toString())) data class CrossProfileError( override val appIcon: Drawable, override var displayLabel: String ) : InternalBacklinksData(appIcon, displayLabel) data class CrossProfileError(private val icon: Drawable, private var label: String) : InternalBacklinksData(BacklinkDisplayInfo(icon, label)) } /** Loading @@ -54,11 +65,16 @@ internal data class InternalTaskInfo( val userId: Int, val packageManager: PackageManager ) { fun getTopActivityNameForDebugLogging(): String = topActivityInfo.name val topActivityNameForDebugLogging: String = topActivityInfo.name val topActivityPackageName: String = topActivityInfo.packageName val topActivityAppName: String by lazy { topActivityInfo.getAppName(packageManager) } val topActivityAppIcon: Drawable by lazy { topActivityInfo.loadIcon(packageManager) } } fun getTopActivityPackageName(): String = topActivityInfo.packageName internal fun ActivityInfo.getAppName(packageManager: PackageManager) = loadLabel(packageManager).toString() fun getTopActivityAppName(): String = topActivityInfo.loadLabel(packageManager).toString() internal fun ActivityInfo.getAppIcon(packageManager: PackageManager) = loadIcon(packageManager) fun getTopActivityAppIcon(): Drawable = topActivityInfo.loadIcon(packageManager) } /** A class to hold data that is used for displaying backlink to user in SysUI activity. */ internal data class BacklinkDisplayInfo(val appIcon: Drawable, var displayLabel: String) packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java +75 −8 Original line number Diff line number Diff line Loading @@ -24,6 +24,7 @@ import static android.content.Intent.ACTION_MAIN; import static android.content.Intent.ACTION_VIEW; import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; import static android.content.Intent.CATEGORY_LAUNCHER; import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; import static android.view.Display.DEFAULT_DISPLAY; import static com.google.common.truth.Truth.assertThat; Loading @@ -32,6 +33,7 @@ import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.reset; Loading Loading @@ -73,6 +75,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.MockitoAnnotations; Loading Loading @@ -108,19 +111,24 @@ public final class AppClipsViewModelTest extends SysuiTestCase { Context mMockedContext; @Mock private PackageManager mPackageManager; private ArgumentCaptor<Intent> mPackageManagerIntentCaptor; private ArgumentCaptor<Intent> mPackageManagerLauncherIntentCaptor; private ArgumentCaptor<Intent> mPackageManagerBacklinkIntentCaptor; private AppClipsViewModel mViewModel; @Before public void setUp() throws RemoteException { MockitoAnnotations.initMocks(this); mPackageManagerIntentCaptor = ArgumentCaptor.forClass(Intent.class); mPackageManagerLauncherIntentCaptor = ArgumentCaptor.forClass(Intent.class); mPackageManagerBacklinkIntentCaptor = ArgumentCaptor.forClass(Intent.class); // Set up mocking for backlinks. when(mAtmService.getTasks(Integer.MAX_VALUE, false, false, DEFAULT_DISPLAY)) .thenReturn(List.of(createTaskInfoForBacklinksTask())); when(mPackageManager.resolveActivity(mPackageManagerIntentCaptor.capture(), anyInt())) .thenReturn(createBacklinksTaskResolveInfo()); ResolveInfo expectedResolveInfo = createBacklinksTaskResolveInfo(); when(mPackageManager.resolveActivity(mPackageManagerLauncherIntentCaptor.capture(), anyInt())).thenReturn(expectedResolveInfo); when(mPackageManager.queryIntentActivities(mPackageManagerBacklinkIntentCaptor.capture(), eq(MATCH_DEFAULT_ONLY))).thenReturn(List.of(expectedResolveInfo)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); when(mMockedContext.getPackageManager()).thenReturn(mPackageManager); Loading Loading @@ -209,7 +217,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerBacklinkIntentCaptor.getValue(); assertThat(queriedIntent.getData()).isEqualTo(expectedUri); assertThat(queriedIntent.getAction()).isEqualTo(ACTION_VIEW); Loading @@ -225,6 +233,63 @@ public final class AppClipsViewModelTest extends SysuiTestCase { assertThat(result).isEqualTo(mViewModel.getBacklinksLiveData().getValue().get(0)); } @Test public void triggerBacklinks_shouldUpdateBacklinks_withUriForDifferentApp() { Uri expectedUri = Uri.parse("https://android.com"); AssistContent contentWithUri = new AssistContent(); contentWithUri.setWebUri(expectedUri); mockForAssistContent(contentWithUri, BACKLINKS_TASK_ID); // Reset PackageManager mocking done in setup. reset(mPackageManager); String package2 = BACKLINKS_TASK_PACKAGE_NAME + 2; String appName2 = BACKLINKS_TASK_APP_NAME + 2; ResolveInfo resolveInfo2 = createBacklinksTaskResolveInfo(); ActivityInfo activityInfo2 = resolveInfo2.activityInfo; activityInfo2.name = appName2; activityInfo2.packageName = package2; activityInfo2.applicationInfo.packageName = package2; Intent app2LauncherIntent = new Intent(ACTION_MAIN).addCategory( CATEGORY_LAUNCHER).setPackage(package2); when(mPackageManager.resolveActivity(intentEquals(app2LauncherIntent), eq(/* flags= */ 0))) .thenReturn(resolveInfo2); Intent uriIntent = new Intent(ACTION_VIEW).setData(expectedUri); when(mPackageManager.queryIntentActivities(intentEquals(uriIntent), eq(MATCH_DEFAULT_ONLY))) .thenReturn(List.of(resolveInfo2)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); BacklinksData result = (BacklinksData) mViewModel.mSelectedBacklinksLiveData.getValue(); ClipData clipData = result.getClipData(); ClipDescription resultDescription = clipData.getDescription(); assertThat(resultDescription.getLabel().toString()).isEqualTo(appName2); assertThat(resultDescription.getMimeType(0)).isEqualTo(MIMETYPE_TEXT_URILIST); assertThat(clipData.getItemCount()).isEqualTo(1); assertThat(clipData.getItemAt(0).getUri()).isEqualTo(expectedUri); assertThat(mViewModel.getBacklinksLiveData().getValue().size()).isEqualTo(1); } private static class IntentMatcher implements ArgumentMatcher<Intent> { private final Intent mExpectedIntent; IntentMatcher(Intent expectedIntent) { mExpectedIntent = expectedIntent; } @Override public boolean matches(Intent actualIntent) { return actualIntent != null && mExpectedIntent.filterEquals(actualIntent); } } private static Intent intentEquals(Intent intent) { return argThat(new IntentMatcher(intent)); } @Test public void triggerBacklinks_withNonResolvableUri_usesMainLauncherIntent() { Uri expectedUri = Uri.parse("https://developers.android.com"); Loading @@ -249,7 +314,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerBacklinkIntentCaptor.getValue(); assertThat(queriedIntent.getPackage()).isEqualTo(expectedIntent.getPackage()); BacklinksData result = (BacklinksData) mViewModel.mSelectedBacklinksLiveData.getValue(); Loading Loading @@ -283,7 +348,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerLauncherIntentCaptor.getValue(); assertThat(queriedIntent.getPackage()).isEqualTo(BACKLINKS_TASK_PACKAGE_NAME); assertThat(queriedIntent.getAction()).isEqualTo(ACTION_MAIN); assertThat(queriedIntent.getCategories()).containsExactly(CATEGORY_LAUNCHER); Loading Loading @@ -356,7 +421,9 @@ public final class AppClipsViewModelTest extends SysuiTestCase { // For each task, the logic queries PM 3 times, twice for verifying if an app can be // launched via launcher and once with the data provided in backlink intent. when(mPackageManager.resolveActivity(any(), anyInt())).thenReturn(resolveInfo1, resolveInfo1, resolveInfo1, resolveInfo2, resolveInfo2, resolveInfo2); resolveInfo1, resolveInfo2, resolveInfo2); when(mPackageManager.queryIntentActivities(any(Intent.class), eq(MATCH_DEFAULT_ONLY))) .thenReturn(List.of(resolveInfo1)).thenReturn(List.of(resolveInfo2)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); when(mAtmService.getTasks(Integer.MAX_VALUE, false, false, DEFAULT_DISPLAY)) .thenReturn(List.of(runningTaskInfo1, runningTaskInfo2)); Loading Loading
packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java +92 −44 Original line number Diff line number Diff line Loading @@ -28,9 +28,9 @@ import android.app.TaskInfo; import android.app.WindowConfiguration; import android.app.assist.AssistContent; import android.content.ClipData; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; Loading Loading @@ -249,7 +249,6 @@ final class AppClipsViewModel extends ViewModel { .map(taskInfo -> new InternalTaskInfo(taskInfo.topActivityInfo, taskInfo.taskId, taskInfo.userId, getPackageManagerForUser(taskInfo.userId))) .filter(this::canAppStartThroughLauncher) .map(this::getBacklinksDataForTaskInfo) .toList(), mBgExecutor); Loading @@ -257,6 +256,17 @@ final class AppClipsViewModel extends ViewModel { return Futures.transformAsync(backlinksNestedListFuture, Futures::allAsList, mBgExecutor); } private PackageManager getPackageManagerForUser(int userId) { // If app clips was launched as the same user, then reuse the available PM from mContext. if (mContext.getUserId() == userId) { return mContext.getPackageManager(); } // PackageManager required for a different user, create its context and return its PM. UserHandle userHandle = UserHandle.of(userId); return mContext.createContextAsUser(userHandle, /* flags= */ 0).getPackageManager(); } /** * Returns all tasks on a given display after querying {@link IActivityTaskManager} from the * {@link #mBgExecutor}. Loading Loading @@ -310,17 +320,6 @@ final class AppClipsViewModel extends ViewModel { && taskInfo.getActivityType() == WindowConfiguration.ACTIVITY_TYPE_STANDARD; } /** * Returns whether the app represented by the {@link InternalTaskInfo} can be launched through * the all apps tray by a user. */ private boolean canAppStartThroughLauncher(InternalTaskInfo internalTaskInfo) { // Use Intent.resolveActivity API to check if the intent resolves as that is what Android // uses internally when apps use Context.startActivity. return getMainLauncherIntentForTask(internalTaskInfo) .resolveActivity(internalTaskInfo.getPackageManager()) != null; } /** * Returns an {@link InternalBacklinksData} that represents the Backlink data internally, which * is captured by querying the system using {@link TaskInfo#taskId}. Loading Loading @@ -390,11 +389,14 @@ final class AppClipsViewModel extends ViewModel { internalTaskInfo.getTaskId(), internalTaskInfo.getTopActivityNameForDebugLogging())); String appName = internalTaskInfo.getTopActivityAppName(); Drawable appIcon = internalTaskInfo.getTopActivityAppIcon(); ClipData mainLauncherIntent = ClipData.newIntent(appName, getMainLauncherIntentForTask(internalTaskInfo)); InternalBacklinksData fallback = new BacklinksData(mainLauncherIntent, appIcon); String screenshottedAppName = internalTaskInfo.getTopActivityAppName(); Drawable screenshottedAppIcon = internalTaskInfo.getTopActivityAppIcon(); Intent screenshottedAppMainLauncherIntent = getMainLauncherIntentForTask( internalTaskInfo.getTopActivityPackageName(), internalTaskInfo.getPackageManager()); ClipData screenshottedAppMainLauncherClipData = ClipData.newIntent(screenshottedAppName, screenshottedAppMainLauncherIntent); InternalBacklinksData fallback = new BacklinksData(screenshottedAppMainLauncherClipData, screenshottedAppIcon); if (content == null) { return fallback; } Loading @@ -406,10 +408,14 @@ final class AppClipsViewModel extends ViewModel { Uri uri = content.getWebUri(); Intent backlinksIntent = new Intent(ACTION_VIEW).setData(uri); if (doesIntentResolveToSameTask(backlinksIntent, internalTaskInfo)) { BacklinkDisplayInfo backlinkDisplayInfo = getInfoThatResolvesIntent(backlinksIntent, internalTaskInfo); if (backlinkDisplayInfo != null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getBacklinksDataFromAssistContent: using app provided uri"); return new BacklinksData(ClipData.newRawUri(appName, uri), appIcon); return new BacklinksData( ClipData.newRawUri(backlinkDisplayInfo.getDisplayLabel(), uri), backlinkDisplayInfo.getAppIcon()); } } Loading @@ -419,10 +425,14 @@ final class AppClipsViewModel extends ViewModel { () -> "getBacklinksDataFromAssistContent: app has provided an intent"); Intent backlinksIntent = content.getIntent(); if (doesIntentResolveToSameTask(backlinksIntent, internalTaskInfo)) { BacklinkDisplayInfo backlinkDisplayInfo = getInfoThatResolvesIntent(backlinksIntent, internalTaskInfo); if (backlinkDisplayInfo != null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getBacklinksDataFromAssistContent: using app provided intent"); return new BacklinksData(ClipData.newIntent(appName, backlinksIntent), appIcon); return new BacklinksData( ClipData.newIntent(backlinkDisplayInfo.getDisplayLabel(), backlinksIntent), backlinkDisplayInfo.getAppIcon()); } } Loading @@ -431,27 +441,76 @@ final class AppClipsViewModel extends ViewModel { return fallback; } private boolean doesIntentResolveToSameTask(Intent intentToResolve, InternalTaskInfo requiredTaskInfo) { PackageManager packageManager = requiredTaskInfo.getPackageManager(); ComponentName resolvedComponent = intentToResolve.resolveActivity(packageManager); if (resolvedComponent == null) { return false; /** * Returns {@link BacklinkDisplayInfo} for the app that would resolve the provided backlink * {@link Intent}. * * <p>The method uses the {@link PackageManager} available in the provided * {@link InternalTaskInfo}. * * <p>This method returns {@code null} if Android is not able to resolve the backlink intent or * if the resolved app does not have an icon in the launcher. */ @Nullable private BacklinkDisplayInfo getInfoThatResolvesIntent(Intent backlinkIntent, InternalTaskInfo internalTaskInfo) { PackageManager packageManager = internalTaskInfo.getPackageManager(); // Query for all available activities as there is a chance that multiple apps could resolve // the intent. In such cases the normal `intent.resolveActivity` API returns the activity // resolver info which isn't helpful for further checks. Also, using MATCH_DEFAULT_ONLY flag // is required as that flag will be used when the notes app builds the intent and calls // startActivity with the intent. List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(backlinkIntent, PackageManager.MATCH_DEFAULT_ONLY); if (resolveInfos.isEmpty()) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: could not resolve backlink intent"); return null; } // Only use the first result as the list is ordered from best match to worst and Android // will also use the best match with `intent.startActivity` API which notes app will use. ActivityInfo activityInfo = resolveInfos.get(0).activityInfo; if (activityInfo == null) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: could not find activity info for backlink " + "intent"); return null; } // Ignore resolved backlink app if users cannot start it through all apps tray. if (!canAppStartThroughLauncher(activityInfo.packageName, packageManager)) { DebugLogger.INSTANCE.logcatMessage(this, () -> "getInfoThatResolvesIntent: ignoring resolved backlink app as it cannot" + " start through launcher"); return null; } String requiredPackageName = requiredTaskInfo.getTopActivityPackageName(); return resolvedComponent.getPackageName().equals(requiredPackageName); Drawable appIcon = InternalBacklinksDataKt.getAppIcon(activityInfo, packageManager); String appName = InternalBacklinksDataKt.getAppName(activityInfo, packageManager); return new BacklinkDisplayInfo(appIcon, appName); } /** * Returns whether the app represented by the provided {@code pkgName} can be launched through * the all apps tray by the user. */ private static boolean canAppStartThroughLauncher(String pkgName, PackageManager pkgManager) { // Use Intent.resolveActivity API to check if the intent resolves as that is what Android // uses internally when apps use Context.startActivity. return getMainLauncherIntentForTask(pkgName, pkgManager) .resolveActivity(pkgManager) != null; } private Intent getMainLauncherIntentForTask(InternalTaskInfo internalTaskInfo) { String pkgName = internalTaskInfo.getTopActivityPackageName(); private static Intent getMainLauncherIntentForTask(String pkgName, PackageManager packageManager) { Intent intent = new Intent(ACTION_MAIN).addCategory(CATEGORY_LAUNCHER).setPackage(pkgName); // Not all apps use DEFAULT_CATEGORY for their main launcher activity so the exact component // needs to be queried and set on the Intent in order for note-taking apps to be able to // start this intent. When starting an activity with an implicit intent, Android adds the // DEFAULT_CATEGORY flag otherwise it fails to resolve the intent. PackageManager packageManager = internalTaskInfo.getPackageManager(); ResolveInfo resolvedActivity = packageManager.resolveActivity(intent, /* flags= */ 0); if (resolvedActivity != null) { intent.setComponent(resolvedActivity.getComponentInfo().getComponentName()); Loading @@ -460,17 +519,6 @@ final class AppClipsViewModel extends ViewModel { return intent; } private PackageManager getPackageManagerForUser(int userId) { // If app clips was launched as the same user, then reuse the available PM from mContext. if (mContext.getUserId() == userId) { return mContext.getPackageManager(); } // PackageManager required for a different user, create its context and return its PM. UserHandle userHandle = UserHandle.of(userId); return mContext.createContextAsUser(userHandle, /* flags= */ 0).getPackageManager(); } /** Helper factory to help with injecting {@link AppClipsViewModel}. */ static final class Factory implements ViewModelProvider.Factory { Loading
packages/SystemUI/src/com/android/systemui/screenshot/appclips/InternalBacklinksData.kt +29 −13 Original line number Diff line number Diff line Loading @@ -27,16 +27,27 @@ import android.graphics.drawable.Drawable * represent error when necessary. */ internal sealed class InternalBacklinksData( open val appIcon: Drawable, open var displayLabel: String // Fields from this object are made accessible through accessors to keep call sites simpler. private val backlinkDisplayInfo: BacklinkDisplayInfo, ) { data class BacklinksData(val clipData: ClipData, override val appIcon: Drawable) : InternalBacklinksData(appIcon, clipData.description.label.toString()) // Use separate field to access display label so that callers don't have to access through // internal object. var displayLabel: String get() = backlinkDisplayInfo.displayLabel set(value) { backlinkDisplayInfo.displayLabel = value } // Use separate field to access app icon so that callers don't have to access through internal // object. val appIcon: Drawable get() = backlinkDisplayInfo.appIcon data class BacklinksData(val clipData: ClipData, private val icon: Drawable) : InternalBacklinksData(BacklinkDisplayInfo(icon, clipData.description.label.toString())) data class CrossProfileError( override val appIcon: Drawable, override var displayLabel: String ) : InternalBacklinksData(appIcon, displayLabel) data class CrossProfileError(private val icon: Drawable, private var label: String) : InternalBacklinksData(BacklinkDisplayInfo(icon, label)) } /** Loading @@ -54,11 +65,16 @@ internal data class InternalTaskInfo( val userId: Int, val packageManager: PackageManager ) { fun getTopActivityNameForDebugLogging(): String = topActivityInfo.name val topActivityNameForDebugLogging: String = topActivityInfo.name val topActivityPackageName: String = topActivityInfo.packageName val topActivityAppName: String by lazy { topActivityInfo.getAppName(packageManager) } val topActivityAppIcon: Drawable by lazy { topActivityInfo.loadIcon(packageManager) } } fun getTopActivityPackageName(): String = topActivityInfo.packageName internal fun ActivityInfo.getAppName(packageManager: PackageManager) = loadLabel(packageManager).toString() fun getTopActivityAppName(): String = topActivityInfo.loadLabel(packageManager).toString() internal fun ActivityInfo.getAppIcon(packageManager: PackageManager) = loadIcon(packageManager) fun getTopActivityAppIcon(): Drawable = topActivityInfo.loadIcon(packageManager) } /** A class to hold data that is used for displaying backlink to user in SysUI activity. */ internal data class BacklinkDisplayInfo(val appIcon: Drawable, var displayLabel: String)
packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java +75 −8 Original line number Diff line number Diff line Loading @@ -24,6 +24,7 @@ import static android.content.Intent.ACTION_MAIN; import static android.content.Intent.ACTION_VIEW; import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; import static android.content.Intent.CATEGORY_LAUNCHER; import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; import static android.view.Display.DEFAULT_DISPLAY; import static com.google.common.truth.Truth.assertThat; Loading @@ -32,6 +33,7 @@ import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.reset; Loading Loading @@ -73,6 +75,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.MockitoAnnotations; Loading Loading @@ -108,19 +111,24 @@ public final class AppClipsViewModelTest extends SysuiTestCase { Context mMockedContext; @Mock private PackageManager mPackageManager; private ArgumentCaptor<Intent> mPackageManagerIntentCaptor; private ArgumentCaptor<Intent> mPackageManagerLauncherIntentCaptor; private ArgumentCaptor<Intent> mPackageManagerBacklinkIntentCaptor; private AppClipsViewModel mViewModel; @Before public void setUp() throws RemoteException { MockitoAnnotations.initMocks(this); mPackageManagerIntentCaptor = ArgumentCaptor.forClass(Intent.class); mPackageManagerLauncherIntentCaptor = ArgumentCaptor.forClass(Intent.class); mPackageManagerBacklinkIntentCaptor = ArgumentCaptor.forClass(Intent.class); // Set up mocking for backlinks. when(mAtmService.getTasks(Integer.MAX_VALUE, false, false, DEFAULT_DISPLAY)) .thenReturn(List.of(createTaskInfoForBacklinksTask())); when(mPackageManager.resolveActivity(mPackageManagerIntentCaptor.capture(), anyInt())) .thenReturn(createBacklinksTaskResolveInfo()); ResolveInfo expectedResolveInfo = createBacklinksTaskResolveInfo(); when(mPackageManager.resolveActivity(mPackageManagerLauncherIntentCaptor.capture(), anyInt())).thenReturn(expectedResolveInfo); when(mPackageManager.queryIntentActivities(mPackageManagerBacklinkIntentCaptor.capture(), eq(MATCH_DEFAULT_ONLY))).thenReturn(List.of(expectedResolveInfo)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); when(mMockedContext.getPackageManager()).thenReturn(mPackageManager); Loading Loading @@ -209,7 +217,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerBacklinkIntentCaptor.getValue(); assertThat(queriedIntent.getData()).isEqualTo(expectedUri); assertThat(queriedIntent.getAction()).isEqualTo(ACTION_VIEW); Loading @@ -225,6 +233,63 @@ public final class AppClipsViewModelTest extends SysuiTestCase { assertThat(result).isEqualTo(mViewModel.getBacklinksLiveData().getValue().get(0)); } @Test public void triggerBacklinks_shouldUpdateBacklinks_withUriForDifferentApp() { Uri expectedUri = Uri.parse("https://android.com"); AssistContent contentWithUri = new AssistContent(); contentWithUri.setWebUri(expectedUri); mockForAssistContent(contentWithUri, BACKLINKS_TASK_ID); // Reset PackageManager mocking done in setup. reset(mPackageManager); String package2 = BACKLINKS_TASK_PACKAGE_NAME + 2; String appName2 = BACKLINKS_TASK_APP_NAME + 2; ResolveInfo resolveInfo2 = createBacklinksTaskResolveInfo(); ActivityInfo activityInfo2 = resolveInfo2.activityInfo; activityInfo2.name = appName2; activityInfo2.packageName = package2; activityInfo2.applicationInfo.packageName = package2; Intent app2LauncherIntent = new Intent(ACTION_MAIN).addCategory( CATEGORY_LAUNCHER).setPackage(package2); when(mPackageManager.resolveActivity(intentEquals(app2LauncherIntent), eq(/* flags= */ 0))) .thenReturn(resolveInfo2); Intent uriIntent = new Intent(ACTION_VIEW).setData(expectedUri); when(mPackageManager.queryIntentActivities(intentEquals(uriIntent), eq(MATCH_DEFAULT_ONLY))) .thenReturn(List.of(resolveInfo2)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); BacklinksData result = (BacklinksData) mViewModel.mSelectedBacklinksLiveData.getValue(); ClipData clipData = result.getClipData(); ClipDescription resultDescription = clipData.getDescription(); assertThat(resultDescription.getLabel().toString()).isEqualTo(appName2); assertThat(resultDescription.getMimeType(0)).isEqualTo(MIMETYPE_TEXT_URILIST); assertThat(clipData.getItemCount()).isEqualTo(1); assertThat(clipData.getItemAt(0).getUri()).isEqualTo(expectedUri); assertThat(mViewModel.getBacklinksLiveData().getValue().size()).isEqualTo(1); } private static class IntentMatcher implements ArgumentMatcher<Intent> { private final Intent mExpectedIntent; IntentMatcher(Intent expectedIntent) { mExpectedIntent = expectedIntent; } @Override public boolean matches(Intent actualIntent) { return actualIntent != null && mExpectedIntent.filterEquals(actualIntent); } } private static Intent intentEquals(Intent intent) { return argThat(new IntentMatcher(intent)); } @Test public void triggerBacklinks_withNonResolvableUri_usesMainLauncherIntent() { Uri expectedUri = Uri.parse("https://developers.android.com"); Loading @@ -249,7 +314,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerBacklinkIntentCaptor.getValue(); assertThat(queriedIntent.getPackage()).isEqualTo(expectedIntent.getPackage()); BacklinksData result = (BacklinksData) mViewModel.mSelectedBacklinksLiveData.getValue(); Loading Loading @@ -283,7 +348,7 @@ public final class AppClipsViewModelTest extends SysuiTestCase { mViewModel.triggerBacklinks(Collections.emptySet(), DEFAULT_DISPLAY); waitForIdleSync(); Intent queriedIntent = mPackageManagerIntentCaptor.getValue(); Intent queriedIntent = mPackageManagerLauncherIntentCaptor.getValue(); assertThat(queriedIntent.getPackage()).isEqualTo(BACKLINKS_TASK_PACKAGE_NAME); assertThat(queriedIntent.getAction()).isEqualTo(ACTION_MAIN); assertThat(queriedIntent.getCategories()).containsExactly(CATEGORY_LAUNCHER); Loading Loading @@ -356,7 +421,9 @@ public final class AppClipsViewModelTest extends SysuiTestCase { // For each task, the logic queries PM 3 times, twice for verifying if an app can be // launched via launcher and once with the data provided in backlink intent. when(mPackageManager.resolveActivity(any(), anyInt())).thenReturn(resolveInfo1, resolveInfo1, resolveInfo1, resolveInfo2, resolveInfo2, resolveInfo2); resolveInfo1, resolveInfo2, resolveInfo2); when(mPackageManager.queryIntentActivities(any(Intent.class), eq(MATCH_DEFAULT_ONLY))) .thenReturn(List.of(resolveInfo1)).thenReturn(List.of(resolveInfo2)); when(mPackageManager.loadItemIcon(any(), any())).thenReturn(FAKE_DRAWABLE); when(mAtmService.getTasks(Integer.MAX_VALUE, false, false, DEFAULT_DISPLAY)) .thenReturn(List.of(runningTaskInfo1, runningTaskInfo2)); Loading