Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e77084fb authored by Ajinkya Chalke's avatar Ajinkya Chalke
Browse files

Remove the backlink resolves to same app check

- Backlinks now uses the name and icon of the app that backlink will
  resolve to instead of the screenshotted app.

Flag: com.android.systemui.app_clips_backlinks
Test: atest AppClipsActivityTest AppClipsViewModelTest AppClipsTest
Bug: 356129075
Fix: 356129075
Change-Id: I2a8d80c4d2dbd0e2f444e0e3e870e3aa0efc41d8
parent a0d49f65
Loading
Loading
Loading
Loading
+92 −44
Original line number Diff line number Diff line
@@ -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;
@@ -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);
@@ -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}.
@@ -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}.
@@ -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;
        }
@@ -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());
            }
        }

@@ -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());
            }
        }

@@ -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());
@@ -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 {

+29 −13
Original line number Diff line number Diff line
@@ -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))
}

/**
@@ -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)
+75 −8
Original line number Diff line number Diff line
@@ -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;
@@ -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;
@@ -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;

@@ -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);

@@ -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);

@@ -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");
@@ -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();
@@ -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);
@@ -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));