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

Commit e1add8bb authored by Joe Antonetti's avatar Joe Antonetti Committed by Android (Google) Code Review
Browse files

Merge "[Handoff][2/N] Handle Fallback URIs when launching Handoff Activities" into main

parents 859512e3 cb6fddd8
Loading
Loading
Loading
Loading
+174 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.server.companion.datatransfer.continuity.handoff;

import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.HandoffActivityData;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.UserHandle;
import android.util.Slog;

import java.util.List;
import java.util.Objects;

final class HandoffActivityStarter {

    private static final String TAG = "HandoffActivityStarter";

    /**
     * Starts the activities specified by {@code handoffActivityData}.
     *
     * @param context the context to use for starting the activities.
     * @param handoffActivityData the list of activities to start.
     * @return {@code true} if an activity was started (including web fallback),
     * {@code false} otherwise.
     */
    public static boolean start(
        @NonNull Context context,
        @NonNull List<HandoffActivityData> handoffActivityData) {

        Objects.requireNonNull(context);
        Objects.requireNonNull(handoffActivityData);

        if (handoffActivityData.isEmpty()) {
            Slog.w(TAG, "No activities to start.");
            return false;
        }

        // Attempt to launch the activities natively.
        if (startNativeActivities(context, handoffActivityData)) {
            return true;
        }

        // Attempt to launch a web fallback.
        Uri fallbackUri = handoffActivityData.get(handoffActivityData.size() - 1).getFallbackUri();
        return startWebFallback(context, fallbackUri);
    }

    private static boolean startNativeActivities(
        @NonNull Context context,
        @NonNull List<HandoffActivityData> handoffActivityData) {

        Objects.requireNonNull(context);
        Objects.requireNonNull(handoffActivityData);

        if (handoffActivityData.isEmpty()) {
            Slog.w(TAG, "No activities to start.");
            return false;
        }

        // Try to build an intent for the top activity handed off. This will be used as a fallback
        // if any of the lower activities cannot be launched.
        Intent topActivityIntent = createIntent(context,
            handoffActivityData.get(handoffActivityData.size() - 1));

        // If the top activity cannot launch, we don't have anything to fall back to and should
        // return false.
        if (topActivityIntent == null) {
            Slog.w(TAG, "Top activity cannot be launched.");
            return false;
        }

        Intent[] intentsToLaunch = new Intent[handoffActivityData.size()];
        for (int i = 0; i < handoffActivityData.size(); i++) {
            Intent intent = createIntent(context, handoffActivityData.get(i));
            if (intent != null) {
                intentsToLaunch[i] = intent;
            } else {
                Slog.w(
                    TAG,
                    "Failed to create intent for activity, falling back to launch top activity.");
                return startIntents(context, new Intent[] {topActivityIntent});
            }
        }

        if (!startIntents(context, intentsToLaunch)) {
            Slog.w(TAG, "Failed to launch activities, falling back to launch top activity.");
            return startIntents(context, new Intent[] {topActivityIntent});
        }

        return true;
    }

    private static boolean startWebFallback(@NonNull Context context, @Nullable Uri fallbackUri) {
        Objects.requireNonNull(context);

        if (fallbackUri == null) {
            Slog.w(TAG, "No fallback URI specified.");
            return false;
        }

        Intent intent = new Intent(Intent.ACTION_VIEW, fallbackUri);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        // Add a flag to allow this URI to be handled by a non-browser app.
        intent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
        intent.addCategory(Intent.CATEGORY_BROWSABLE);
        return startIntents(context, new Intent[] {intent});
    }

    private static boolean startIntents(@NonNull Context context, @NonNull Intent[] intents) {
        Objects.requireNonNull(context);
        Objects.requireNonNull(intents);

        intents[intents.length - 1].addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        try {
            int result = context.startActivitiesAsUser(
                intents,
                ActivityOptions.makeBasic().toBundle(),
                UserHandle.CURRENT);
            Slog.i(TAG, "Launched activities: " + result);
            return result == ActivityManager.START_SUCCESS;
        } catch (ActivityNotFoundException e) {
            Slog.w(TAG, "Unable to launch activities: " + e.getMessage());
            return false;
        }
    }

    @Nullable
    private static Intent createIntent(
        @NonNull Context context,
        @NonNull HandoffActivityData handoffActivityData) {

        Objects.requireNonNull(context);
        Objects.requireNonNull(handoffActivityData);

        // Check if the package is installed on this device.
        PackageManager packageManager = context.getPackageManager();
        try {
            packageManager.getActivityInfo(
                handoffActivityData.getComponentName(),
                PackageManager.MATCH_DEFAULT_ONLY);
        } catch (PackageManager.NameNotFoundException e) {
            Slog.w(TAG, "Package not installed on device: "
                + handoffActivityData.getComponentName().getPackageName());
            return null;
        }

        Intent intent = new Intent();
        intent.setComponent(handoffActivityData.getComponentName());
        intent.putExtras(new Bundle(handoffActivityData.getExtras()));
        return intent;
    }
}
 No newline at end of file
+16 −21
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import com.android.server.companion.datatransfer.continuity.messages.HandoffRequ
import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestResultMessage;
import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage;
import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessageSerializer;
import com.android.server.companion.datatransfer.continuity.handoff.HandoffActivityStarter;

import android.app.ActivityOptions;
import android.app.HandoffActivityData;
@@ -128,28 +129,22 @@ public class OutboundHandoffRequestController {
                return;
            }

            launchHandoffTask(
            if (!HandoffActivityStarter.start(
                    mContext,
                    handoffRequestResultMessage.activities())) {

                finishHandoffRequest(
                    associationId,
                    handoffRequestResultMessage.taskId(),
                handoffRequestResultMessage.activities());
                    HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK);
                return;
            } else {
                finishHandoffRequest(
                    associationId,
                    handoffRequestResultMessage.taskId(),
                    HANDOFF_REQUEST_RESULT_SUCCESS);
            }
        }

    private void launchHandoffTask(
        int associationId,
        int taskId,
        List<HandoffActivityData> activities) {

        HandoffActivityData topActivity = activities.get(0);
        Intent intent = new Intent();
        intent.setComponent(topActivity.getComponentName());
        intent.putExtras(new Bundle(topActivity.getExtras()));
        // TODO (joeantonetti): Handle failures here and fall back to a web URL.
        mContext.startActivityAsUser(
            intent,
            ActivityOptions.makeBasic().toBundle(),
            UserHandle.CURRENT);
        finishHandoffRequest(associationId, taskId, HANDOFF_REQUEST_RESULT_SUCCESS);
    }

    private void finishHandoffRequest(int associationId, int taskId, int statusCode) {
+289 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.server.companion.datatransfer.continuity.handoff;

import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doNothing;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.app.HandoffActivityData;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.PersistableBundle;
import android.os.UserHandle;
import android.platform.test.annotations.Presubmit;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;

import com.android.server.companion.datatransfer.continuity.handoff.HandoffActivityStarter;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.util.List;
import java.util.UUID;

@Presubmit
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class HandoffActivityStarterTest {

    @Mock private Context mMockContext;
    @Mock private PackageManager mMockPackageManager;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        when(mMockContext.getPackageManager()).thenReturn(mMockPackageManager);
    }

    @Test
    public void start_emptyList_returnsFalse() {
        boolean result = HandoffActivityStarter.start(mMockContext, List.of());
        assertThat(result).isFalse();
        verify(mMockContext, never()).startActivityAsUser(any(), any());
        verify(mMockContext, never()).startActivitiesAsUser(any(), any(), any());
    }

    @Test
    public void start_singleActivity_startsSuccessfully() throws Exception {
        // Create a HandoffActivityData mapped to an installed package.
        HandoffActivityData activityData = createTestHandoffActivity(true, false);

        // Start the activity.
        boolean result = HandoffActivityStarter.start(mMockContext, List.of(activityData));

        // Verify the activity was started.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(1);
        verifyActivityStartAttempted(attempts.get(0), List.of(activityData));
    }

    @Test
    public void start_multipleActivities_startsSuccessfully() throws Exception {
        // Create test HandoffActivityData mapped to the installed packages.
        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(true, false));

        // Make attempts to start activities return success.
        when(mMockContext.startActivitiesAsUser(any(), any(), any()))
                .thenReturn(ActivityManager.START_SUCCESS);

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify the activities were started.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(1);
        verifyActivityStartAttempted(attempts.get(0), handoffActivityData);
    }

    @Test
    public void start_nonTopActivityNotInstalled_onlyStartsTopActivity() throws Exception {
        // Create test HandoffActivityData. The top activity is installed, but the second activity
        // is not.
        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(false, false),
            createTestHandoffActivity(true, false));

        // Make any attempts to start activities return success.
        when(mMockContext.startActivitiesAsUser(any(), any(), any()))
            .thenReturn(ActivityManager.START_SUCCESS);

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify only one launch attempt was made, and it is only for the top activity.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(1);
        verifyActivityStartAttempted(attempts.get(0), List.of(handoffActivityData.get(1)));
    }

    @Test
    public void start_topActivityNotInstalled_fallsBackToWeb() throws Exception {
        // Create a list of test HandoffActivityData. The top activity is not installed, but has
        // a fallback URI.
        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(false, true));

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify only one launch attempt was made, and it is for the fallback URI.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(1);
        verifyActivityStartAttempted(attempts.get(0), handoffActivityData.get(1).getFallbackUri());
    }

    @Test
    public void start_topActivityNotInstalledAndNoFallbackURI_returnsFalse() throws Exception {
        // Create test HandoffActivityData. The top activity is not installed, and has no fallback
        // URI.
        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(false, false));

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify no launch attempts were made.
        assertThat(result).isFalse();
        verify(mMockContext, never()).startActivitiesAsUser(any(), any(), any());
    }

    @Test
    public void start_startActivityFailsForAllActivities_reattemptsWithTopActivity()
        throws Exception {

        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(true, false));

        // Make the first attempt to start activities fail, and the second attempt succeed.
        when(mMockContext.startActivitiesAsUser(any(), any(), any()))
                .thenReturn(
                    ActivityManager.START_ABORTED,
                    ActivityManager.START_SUCCESS);

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify two launch attempts were made - one for all activities, and one for the top
        // activity.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(2);
        verifyActivityStartAttempted(attempts.get(0), handoffActivityData);
        verifyActivityStartAttempted(attempts.get(1), List.of(handoffActivityData.get(1)));
    }

    @Test
    public void start_startActivityFailsForBothActivities_fallsBackToWeb() throws Exception {
        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(true, true));

        // Make the first two attempts to start activities fail, and the third attempt succeed.
        when(mMockContext.startActivitiesAsUser(any(), any(), any()))
                .thenReturn(
                    ActivityManager.START_ABORTED,
                    ActivityManager.START_ABORTED,
                    ActivityManager.START_SUCCESS);

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify three launch attempts were made - one for all activities, one for the top
        // activity, and one for the fallback URI.
        assertThat(result).isTrue();
        List<Intent[]> attempts = getActivityStartAttempts(3);
        verifyActivityStartAttempted(attempts.get(0), handoffActivityData);
        verifyActivityStartAttempted(attempts.get(1), List.of(handoffActivityData.get(1)));
        verifyActivityStartAttempted(attempts.get(2), handoffActivityData.get(1).getFallbackUri());
    }

    @Test
    public void start_noActivityCanLaunchAndNoFallbackURI_returnsFalse() throws Exception {

        List<HandoffActivityData> handoffActivityData = List.of(
            createTestHandoffActivity(true, false),
            createTestHandoffActivity(true, false));

        // Make all attempts to start activities fail.
        when(mMockContext.startActivitiesAsUser(any(), any(), any()))
                .thenReturn(
                    ActivityManager.START_ABORTED,
                    ActivityManager.START_ABORTED);

        boolean result = HandoffActivityStarter.start(mMockContext, handoffActivityData);

        // Verify two launch attempts were made - one for all activities, and one for the top
        // activity.
        assertThat(result).isFalse();
        List<Intent[]> attempts = getActivityStartAttempts(2);
        verifyActivityStartAttempted(attempts.get(0), handoffActivityData);
        verifyActivityStartAttempted(attempts.get(1), List.of(handoffActivityData.get(1)));
    }

    private static void verifyActivityStartAttempted(Intent[] actual, Uri expectedUri) {
        assertThat(actual).hasLength(1);
        assertThat(actual[0].getAction()).isEqualTo(Intent.ACTION_VIEW);
        assertThat(actual[0].getData()).isEqualTo(expectedUri);
    }

    private static void verifyActivityStartAttempted(
        Intent[] actual,
        List<HandoffActivityData> expected) {

        assertThat(actual).hasLength(expected.size());
        for (int i = 0; i < actual.length; i++) {
            assertThat(actual[i].getComponent()).isEqualTo(expected.get(i).getComponentName());
            assertThat(actual[i].getExtras().size()).isEqualTo(expected.get(i).getExtras().size());
            for (String key : actual[i].getExtras().keySet()) {
                assertThat(actual[i].getExtras().getString(key))
                    .isEqualTo(expected.get(i).getExtras().getString(key));
            }
        }
    }

    private List<Intent[]> getActivityStartAttempts(int expectedCount) {
        ArgumentCaptor<Intent[]> intentArrayCaptor = ArgumentCaptor.forClass(Intent[].class);
        verify(mMockContext, times(expectedCount)).startActivitiesAsUser(
            intentArrayCaptor.capture(),
            any(),
            any());
        return intentArrayCaptor.getAllValues();
    }

    private HandoffActivityData createTestHandoffActivity(
        boolean installed,
        boolean hasFallbackUri) throws Exception {

        String packageName = "com.example." + UUID.randomUUID().toString();
        ComponentName componentName = new ComponentName(packageName, packageName + ".Activity");
        if (installed) {
            when(mMockPackageManager.getActivityInfo(
                eq(componentName), eq(PackageManager.MATCH_DEFAULT_ONLY)))
                .thenReturn(new ActivityInfo());
        } else {
            when(mMockPackageManager.getActivityInfo(
                eq(componentName), eq(PackageManager.MATCH_DEFAULT_ONLY)))
                .thenThrow(new PackageManager.NameNotFoundException());
        }
        HandoffActivityData.Builder builder = new HandoffActivityData.Builder(componentName);
        PersistableBundle extras = new PersistableBundle();
        extras.putString("key", "value");
        builder.setExtras(extras);
        if (hasFallbackUri) {
            builder.setFallbackUri(Uri.parse("https://www.example.com"));
        }
        return builder.build();
    }
}
 No newline at end of file
+16 −6
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.ArgumentMatchers.any;

import com.android.server.companion.datatransfer.continuity.connectivity.ConnectedAssociationStore;
@@ -37,9 +38,12 @@ import com.android.server.companion.datatransfer.continuity.messages.HandoffRequ
import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage;
import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessageSerializer;

import android.app.ActivityManager;
import android.app.HandoffActivityData;
import android.content.Context;
import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.content.pm.ActivityInfo;
import android.content.Intent;
import android.companion.AssociationInfo;
import android.companion.CompanionDeviceManager;
@@ -64,6 +68,7 @@ public class OutboundHandoffRequestControllerTest {
    private ICompanionDeviceManager mMockCompanionDeviceManagerService;

    @Mock private ConnectedAssociationStore mMockConnectedAssociationStore;
    @Mock private PackageManager mMockPackageManager;

    private OutboundHandoffRequestController mOutboundHandoffRequestController;

@@ -72,6 +77,7 @@ public class OutboundHandoffRequestControllerTest {
        MockitoAnnotations.initMocks(this);
        mContext = createMockContext();
        mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mContext);
        doReturn(mMockPackageManager).when(mContext).getPackageManager();

        mOutboundHandoffRequestController = new OutboundHandoffRequestController(
            mContext,
@@ -110,7 +116,11 @@ public class OutboundHandoffRequestControllerTest {
            = new HandoffActivityData.Builder(expectedComponentName)
                .setExtras(expectedExtras)
                .build();
        doNothing().when(mContext).startActivityAsUser(any(), any(), any());
        when(mMockPackageManager.getActivityInfo(
            eq(expectedComponentName), eq(PackageManager.MATCH_DEFAULT_ONLY)))
            .thenReturn(new ActivityInfo());
        doReturn(ActivityManager.START_SUCCESS)
            .when(mContext).startActivitiesAsUser(any(), any(), any());

        HandoffRequestResultMessage handoffRequestResultMessage = new HandoffRequestResultMessage(
            taskId,
@@ -121,9 +131,9 @@ public class OutboundHandoffRequestControllerTest {
            handoffRequestResultMessage);

        // Verify the intent was launched.
        ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
        verify(mContext, times(1)).startActivityAsUser(intentCaptor.capture(), any(), any());
        Intent actualIntent = intentCaptor.getValue();
        ArgumentCaptor<Intent[]> intentCaptor = ArgumentCaptor.forClass(Intent[].class);
        verify(mContext, times(1)).startActivitiesAsUser(intentCaptor.capture(), any(), any());
        Intent actualIntent = intentCaptor.getValue()[0];
        assertThat(actualIntent.getComponent()).isEqualTo(expectedComponentName);
        assertThat(actualIntent.getExtras().size()).isEqualTo(1);
        for (String key : actualIntent.getExtras().keySet()) {
@@ -212,7 +222,7 @@ public class OutboundHandoffRequestControllerTest {
            failureStatusCode);

        // Verify no intent was launched.
        verify(mContext, never()).startActivityAsUser(any(), any(), any());
        verify(mContext, never()).startActivitiesAsUser(any(), any(), any());
    }

    @Test
@@ -244,7 +254,7 @@ public class OutboundHandoffRequestControllerTest {
            TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK);

        // Verify no intent was launched.
        verify(mContext, never()).startActivityAsUser(any(), any(), any());
        verify(mContext, never()).startActivitiesAsUser(any(), any(), any());
    }

    private final class HandoffRequestCallbackHolder {