Loading services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/HandoffActivityStarter.java 0 → 100644 +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 services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestController.java +16 −21 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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) { Loading services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/HandoffActivityStarterTest.java 0 → 100644 +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 services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestControllerTest.java +16 −6 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -64,6 +68,7 @@ public class OutboundHandoffRequestControllerTest { private ICompanionDeviceManager mMockCompanionDeviceManagerService; @Mock private ConnectedAssociationStore mMockConnectedAssociationStore; @Mock private PackageManager mMockPackageManager; private OutboundHandoffRequestController mOutboundHandoffRequestController; Loading @@ -72,6 +77,7 @@ public class OutboundHandoffRequestControllerTest { MockitoAnnotations.initMocks(this); mContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mContext); doReturn(mMockPackageManager).when(mContext).getPackageManager(); mOutboundHandoffRequestController = new OutboundHandoffRequestController( mContext, Loading Loading @@ -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, Loading @@ -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()) { Loading Loading @@ -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 Loading Loading @@ -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 { Loading Loading
services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/HandoffActivityStarter.java 0 → 100644 +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
services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestController.java +16 −21 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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) { Loading
services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/HandoffActivityStarterTest.java 0 → 100644 +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
services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestControllerTest.java +16 −6 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -64,6 +68,7 @@ public class OutboundHandoffRequestControllerTest { private ICompanionDeviceManager mMockCompanionDeviceManagerService; @Mock private ConnectedAssociationStore mMockConnectedAssociationStore; @Mock private PackageManager mMockPackageManager; private OutboundHandoffRequestController mOutboundHandoffRequestController; Loading @@ -72,6 +77,7 @@ public class OutboundHandoffRequestControllerTest { MockitoAnnotations.initMocks(this); mContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mContext); doReturn(mMockPackageManager).when(mContext).getPackageManager(); mOutboundHandoffRequestController = new OutboundHandoffRequestController( mContext, Loading Loading @@ -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, Loading @@ -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()) { Loading Loading @@ -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 Loading Loading @@ -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 { Loading