Loading services/companion/java/com/android/server/companion/datatransfer/continuity/TaskContinuityManagerService.java +13 −2 Original line number Diff line number Diff line Loading @@ -25,7 +25,9 @@ import android.companion.datatransfer.continuity.RemoteTask; import android.content.Context; import android.util.Slog; import com.android.server.companion.datatransfer.continuity.handoff.OutboundHandoffRequestController; import com.android.server.companion.datatransfer.continuity.messages.ContinuityDeviceConnected; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestResultMessage; import com.android.server.companion.datatransfer.continuity.messages.RemoteTaskAddedMessage; import com.android.server.companion.datatransfer.continuity.messages.RemoteTaskRemovedMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; Loading @@ -47,6 +49,7 @@ public final class TaskContinuityManagerService extends SystemService { private static final String TAG = "TaskContinuityManagerService"; private OutboundHandoffRequestController mOutboundHandoffRequestController; private TaskContinuityManagerServiceImpl mTaskContinuityManagerService; private TaskBroadcaster mTaskBroadcaster; private ConnectedAssociationStore mConnectedAssociationStore; Loading @@ -63,6 +66,7 @@ public final class TaskContinuityManagerService extends SystemService { mTaskContinuityMessageReceiver = new TaskContinuityMessageReceiver(context); mRemoteTaskStore = new RemoteTaskStore(mConnectedAssociationStore); mOutboundHandoffRequestController = new OutboundHandoffRequestController(context); } @Override Loading Loading @@ -92,8 +96,10 @@ public final class TaskContinuityManagerService extends SystemService { int associationId, int remoteTaskId, @NonNull IHandoffRequestCallback callback) { // TODO: joeantonetti - Implement this method. mOutboundHandoffRequestController.requestHandoff( associationId, remoteTaskId, callback); } } Loading @@ -119,6 +125,11 @@ public final class TaskContinuityManagerService extends SystemService { associationId, remoteTaskRemovedMessage.taskId()); break; case HandoffRequestResultMessage handoffRequestResultMessage: mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, handoffRequestResultMessage); break; default: Slog.w(TAG, "Received unknown message from device: " + associationId); break; Loading services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestController.java 0 → 100644 +157 −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 android.companion.CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY; import static android.companion.datatransfer.continuity.TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK; import static android.companion.datatransfer.continuity.TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestMessage; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestResultMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; import android.app.HandoffActivityData; import android.content.Context; import android.content.Intent; import android.companion.CompanionDeviceManager; import android.companion.datatransfer.continuity.IHandoffRequestCallback; import android.os.Bundle; import android.os.RemoteException; import android.util.Slog; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Controller for outbound handoff requests. * * This class is responsible for sending handoff request messages to the remote device and handling * the results, either launching the task locally or falling back to a web URL if provided. */ public class OutboundHandoffRequestController { private static final String TAG = "OutboundHandoffRequestController"; private final Context mContext; private final CompanionDeviceManager mCompanionDeviceManager; private final Map<Integer, Map<Integer, List<IHandoffRequestCallback>>> mPendingCallbacks = new HashMap<>(); public OutboundHandoffRequestController(Context context) { mContext = context; mCompanionDeviceManager = context.getSystemService(CompanionDeviceManager.class); } public void requestHandoff(int associationId, int taskId, IHandoffRequestCallback callback) { synchronized (mPendingCallbacks) { if (!mPendingCallbacks.containsKey(associationId)) { mPendingCallbacks.put(associationId, new HashMap<>()); } if (mPendingCallbacks.get(associationId).containsKey(taskId)) { mPendingCallbacks.get(associationId).get(taskId).add(callback); return; } List<IHandoffRequestCallback> callbacks = new ArrayList<>(); callbacks.add(callback); mPendingCallbacks.get(associationId).put(taskId, callbacks); HandoffRequestMessage handoffRequestMessage = new HandoffRequestMessage(taskId); TaskContinuityMessage taskContinuityMessage = new TaskContinuityMessage.Builder() .setData(handoffRequestMessage) .build(); try { mCompanionDeviceManager.sendMessage( CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY, taskContinuityMessage.toBytes(), new int[] {associationId}); } catch (IOException e) { Slog.e(TAG, "Failed to send handoff request message to device " + associationId, e); } } } public void onHandoffRequestResultMessageReceived( int associationId, HandoffRequestResultMessage handoffRequestResultMessage) { synchronized (mPendingCallbacks) { if (handoffRequestResultMessage.statusCode() != HANDOFF_REQUEST_RESULT_SUCCESS) { finishHandoffRequest( associationId, handoffRequestResultMessage.taskId(), handoffRequestResultMessage.statusCode()); } if (handoffRequestResultMessage.activities().isEmpty()) { finishHandoffRequest( associationId, handoffRequestResultMessage.taskId(), HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK); return; } launchHandoffTask( associationId, handoffRequestResultMessage.taskId(), handoffRequestResultMessage.activities()); } } 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.startActivity(intent); finishHandoffRequest(associationId, taskId, HANDOFF_REQUEST_RESULT_SUCCESS); } private void finishHandoffRequest(int associationId, int taskId, int statusCode) { synchronized (mPendingCallbacks) { if (!mPendingCallbacks.containsKey(associationId)) { return; } Map<Integer, List<IHandoffRequestCallback>> pendingCallbacksForAssociation = mPendingCallbacks.get(associationId); if (!pendingCallbacksForAssociation.containsKey(taskId)) { return; } for (IHandoffRequestCallback callback : pendingCallbacksForAssociation.get(taskId)) { try { callback.onHandoffRequestFinished(associationId, taskId, statusCode); } catch (RemoteException e) { Slog.e(TAG, "Failed to notify callback of handoff request result", e); } } pendingCallbacksForAssociation.remove(taskId); } } } No newline at end of file services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/TaskBroadcasterTest.java +4 −16 Original line number Diff line number Diff line Loading @@ -24,6 +24,8 @@ import static org.mockito.Mockito.never; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockContext; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockCompanionDeviceManager; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createAssociationInfo; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createRunningTaskInfo; Loading Loading @@ -77,11 +79,8 @@ public class TaskBroadcasterTest { @Mock private ActivityTaskManager mMockActivityTaskManager; @Mock private ICompanionDeviceManager mMockCompanionDeviceManagerService; private CompanionDeviceManager mCompanionDeviceManager; @Mock private ConnectedAssociationStore mMockConnectedAssociationStore; private TaskBroadcaster mTaskBroadcaster; Loading @@ -89,22 +88,11 @@ public class TaskBroadcasterTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); mMockContext = Mockito.spy( new ContextWrapper( InstrumentationRegistry .getInstrumentation() .getTargetContext())); // Setup fake services. mCompanionDeviceManager = new CompanionDeviceManager( mMockCompanionDeviceManagerService, mMockContext); mMockContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mMockContext); when(mMockContext.getSystemService(Context.ACTIVITY_TASK_SERVICE)) .thenReturn(mMockActivityTaskManager); when(mMockContext.getSystemService(Context.COMPANION_DEVICE_SERVICE)) .thenReturn(mCompanionDeviceManager); // Create TaskBroadcaster. mTaskBroadcaster = new TaskBroadcaster( Loading services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/TaskContinuityTestUtils.java +58 −0 Original line number Diff line number Diff line Loading @@ -16,11 +16,69 @@ package com.android.server.companion.datatransfer.continuity; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import android.app.ActivityManager; import android.companion.AssociationInfo; import android.companion.CompanionDeviceManager; import android.companion.ICompanionDeviceManager; import android.content.Context; import android.content.ContextWrapper; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessageData; import androidx.test.platform.app.InstrumentationRegistry; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.io.IOException; public final class TaskContinuityTestUtils { public static Context createMockContext() { return Mockito.spy( new ContextWrapper( InstrumentationRegistry .getInstrumentation() .getTargetContext())); } public static ICompanionDeviceManager createMockCompanionDeviceManager(Context context) { ICompanionDeviceManager mockCompanionDeviceManagerService = mock(ICompanionDeviceManager.class); CompanionDeviceManager companionDeviceManager = new CompanionDeviceManager( mockCompanionDeviceManagerService, context); when(context.getSystemService(Context.COMPANION_DEVICE_SERVICE)) .thenReturn(companionDeviceManager); return mockCompanionDeviceManagerService; } public static TaskContinuityMessageData verifyMessageSent( ICompanionDeviceManager companionDeviceManagerService, int[] associationIds, int times) throws Exception { ArgumentCaptor<byte[]> messageCaptor = ArgumentCaptor.forClass(byte[].class); verify(companionDeviceManagerService, times(times)).sendMessage( eq(CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY), messageCaptor.capture(), eq(associationIds)); TaskContinuityMessage taskContinuityMessage = new TaskContinuityMessage( messageCaptor.getValue()); return taskContinuityMessage.getData(); } public static ActivityManager.RunningTaskInfo createRunningTaskInfo( int taskId, String label, Loading services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestControllerTest.java 0 → 100644 +240 −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.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockContext; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockCompanionDeviceManager; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.verifyMessageSent; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doNothing; import static org.mockito.ArgumentMatchers.any; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestMessage; 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.TaskContinuityMessageData; import android.app.HandoffActivityData; import android.content.Context; import android.content.ComponentName; import android.content.Intent; import android.companion.CompanionDeviceManager; import android.companion.ICompanionDeviceManager; import android.companion.datatransfer.continuity.IHandoffRequestCallback; import android.companion.datatransfer.continuity.TaskContinuityManager; import android.os.PersistableBundle; import android.os.RemoteException; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import java.util.ArrayList; import java.util.List; public class OutboundHandoffRequestControllerTest { private Context mContext; private ICompanionDeviceManager mMockCompanionDeviceManagerService; private OutboundHandoffRequestController mOutboundHandoffRequestController; @Before public void setUp() { mContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mContext); mOutboundHandoffRequestController = new OutboundHandoffRequestController(mContext); } @Test public void testRequestHandoff_success() throws Exception { int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callbackHolder = new HandoffRequestCallbackHolder(); // Request a handoff to a device. mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callbackHolder.callback); // Verify HandoffRequestMessage was sent. HandoffRequestMessage expectedHandoffRequestMessage = new HandoffRequestMessage(taskId); TaskContinuityMessageData actualMessageData = verifyMessageSent( mMockCompanionDeviceManagerService, new int[] {associationId}, 1); assertThat(actualMessageData).isInstanceOf(HandoffRequestMessage.class); assertThat(actualMessageData).isEqualTo(expectedHandoffRequestMessage); // Simulate a response message. ComponentName expectedComponentName = new ComponentName( "com.example.app", "com.example.app.Activity"); PersistableBundle expectedExtras = new PersistableBundle(); expectedExtras.putString("key", "value"); HandoffActivityData handoffActivityData = new HandoffActivityData.Builder(expectedComponentName) .setExtras(expectedExtras) .build(); doNothing().when(mContext).startActivity(any()); HandoffRequestResultMessage handoffRequestResultMessage = new HandoffRequestResultMessage( taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS, List.of(handoffActivityData)); mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, handoffRequestResultMessage); // Verify the intent was launched. ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(mContext, times(1)).startActivity(intentCaptor.capture()); Intent actualIntent = intentCaptor.getValue(); assertThat(actualIntent.getComponent()).isEqualTo(expectedComponentName); assertThat(actualIntent.getExtras().size()).isEqualTo(1); for (String key : actualIntent.getExtras().keySet()) { assertThat(actualIntent.getExtras().getString(key)) .isEqualTo(expectedExtras.getString(key)); } // Verify the callback was invoked. callbackHolder.verifyInvoked( associationId, taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS); } @Test public void testRequestHandoff_multipleTimes_onlySendsOneMessage() throws Exception { int associationId = 1; int taskId = 1; // Request handoff multiple times. HandoffRequestCallbackHolder firstCallback = new HandoffRequestCallbackHolder(); HandoffRequestCallbackHolder secondCallback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, firstCallback.callback); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, secondCallback.callback); // Verify HandoffRequestMessage was sent only once. TaskContinuityMessageData sentMessage = verifyMessageSent( mMockCompanionDeviceManagerService, new int[] {associationId}, 1); assertThat(sentMessage).isInstanceOf(HandoffRequestMessage.class); } @Test public void testRequestHandoff_failureStatusCode_returnsFailure() { // Request a handoff int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callback.callback); // Simulate a message failure int failureStatusCode = TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_TIMEOUT; mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, new HandoffRequestResultMessage(taskId, failureStatusCode, List.of())); // Verify the callback was invoked. callback.verifyInvoked( associationId, taskId, failureStatusCode); // Verify no intent was launched. verify(mContext, never()).startActivity(any()); } @Test public void testRequestHandoff_noActivities_returnsFailure() { // Request a handoff int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callback.callback); // Return no data for this request. mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, new HandoffRequestResultMessage( taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS, List.of())); // Verify the callback was invoked. callback.verifyInvoked( associationId, taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK); // Verify no intent was launched. verify(mContext, never()).startActivity(any()); } private final class HandoffRequestCallbackHolder { final List<Integer> receivedAssociationIds = new ArrayList<>(); final List<Integer> receivedTaskIds = new ArrayList<>(); final List<Integer> receivedResultCodes = new ArrayList<>(); final IHandoffRequestCallback callback = new IHandoffRequestCallback.Stub() { @Override public void onHandoffRequestFinished( int associationId, int remoteTaskId, int resultCode) throws RemoteException { receivedAssociationIds.add(associationId); receivedTaskIds.add(remoteTaskId); receivedResultCodes.add(resultCode); } }; void verifyInvoked(int associationId, int taskId, int resultCode) { assertThat(receivedAssociationIds).containsExactly(associationId); assertThat(receivedTaskIds).containsExactly(taskId); assertThat(receivedResultCodes).containsExactly(resultCode); } void verifyNotInvoked() { assertThat(receivedAssociationIds).isEmpty(); assertThat(receivedTaskIds).isEmpty(); assertThat(receivedResultCodes).isEmpty(); } } } No newline at end of file Loading
services/companion/java/com/android/server/companion/datatransfer/continuity/TaskContinuityManagerService.java +13 −2 Original line number Diff line number Diff line Loading @@ -25,7 +25,9 @@ import android.companion.datatransfer.continuity.RemoteTask; import android.content.Context; import android.util.Slog; import com.android.server.companion.datatransfer.continuity.handoff.OutboundHandoffRequestController; import com.android.server.companion.datatransfer.continuity.messages.ContinuityDeviceConnected; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestResultMessage; import com.android.server.companion.datatransfer.continuity.messages.RemoteTaskAddedMessage; import com.android.server.companion.datatransfer.continuity.messages.RemoteTaskRemovedMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; Loading @@ -47,6 +49,7 @@ public final class TaskContinuityManagerService extends SystemService { private static final String TAG = "TaskContinuityManagerService"; private OutboundHandoffRequestController mOutboundHandoffRequestController; private TaskContinuityManagerServiceImpl mTaskContinuityManagerService; private TaskBroadcaster mTaskBroadcaster; private ConnectedAssociationStore mConnectedAssociationStore; Loading @@ -63,6 +66,7 @@ public final class TaskContinuityManagerService extends SystemService { mTaskContinuityMessageReceiver = new TaskContinuityMessageReceiver(context); mRemoteTaskStore = new RemoteTaskStore(mConnectedAssociationStore); mOutboundHandoffRequestController = new OutboundHandoffRequestController(context); } @Override Loading Loading @@ -92,8 +96,10 @@ public final class TaskContinuityManagerService extends SystemService { int associationId, int remoteTaskId, @NonNull IHandoffRequestCallback callback) { // TODO: joeantonetti - Implement this method. mOutboundHandoffRequestController.requestHandoff( associationId, remoteTaskId, callback); } } Loading @@ -119,6 +125,11 @@ public final class TaskContinuityManagerService extends SystemService { associationId, remoteTaskRemovedMessage.taskId()); break; case HandoffRequestResultMessage handoffRequestResultMessage: mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, handoffRequestResultMessage); break; default: Slog.w(TAG, "Received unknown message from device: " + associationId); break; Loading
services/companion/java/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestController.java 0 → 100644 +157 −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 android.companion.CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY; import static android.companion.datatransfer.continuity.TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK; import static android.companion.datatransfer.continuity.TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestMessage; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestResultMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; import android.app.HandoffActivityData; import android.content.Context; import android.content.Intent; import android.companion.CompanionDeviceManager; import android.companion.datatransfer.continuity.IHandoffRequestCallback; import android.os.Bundle; import android.os.RemoteException; import android.util.Slog; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Controller for outbound handoff requests. * * This class is responsible for sending handoff request messages to the remote device and handling * the results, either launching the task locally or falling back to a web URL if provided. */ public class OutboundHandoffRequestController { private static final String TAG = "OutboundHandoffRequestController"; private final Context mContext; private final CompanionDeviceManager mCompanionDeviceManager; private final Map<Integer, Map<Integer, List<IHandoffRequestCallback>>> mPendingCallbacks = new HashMap<>(); public OutboundHandoffRequestController(Context context) { mContext = context; mCompanionDeviceManager = context.getSystemService(CompanionDeviceManager.class); } public void requestHandoff(int associationId, int taskId, IHandoffRequestCallback callback) { synchronized (mPendingCallbacks) { if (!mPendingCallbacks.containsKey(associationId)) { mPendingCallbacks.put(associationId, new HashMap<>()); } if (mPendingCallbacks.get(associationId).containsKey(taskId)) { mPendingCallbacks.get(associationId).get(taskId).add(callback); return; } List<IHandoffRequestCallback> callbacks = new ArrayList<>(); callbacks.add(callback); mPendingCallbacks.get(associationId).put(taskId, callbacks); HandoffRequestMessage handoffRequestMessage = new HandoffRequestMessage(taskId); TaskContinuityMessage taskContinuityMessage = new TaskContinuityMessage.Builder() .setData(handoffRequestMessage) .build(); try { mCompanionDeviceManager.sendMessage( CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY, taskContinuityMessage.toBytes(), new int[] {associationId}); } catch (IOException e) { Slog.e(TAG, "Failed to send handoff request message to device " + associationId, e); } } } public void onHandoffRequestResultMessageReceived( int associationId, HandoffRequestResultMessage handoffRequestResultMessage) { synchronized (mPendingCallbacks) { if (handoffRequestResultMessage.statusCode() != HANDOFF_REQUEST_RESULT_SUCCESS) { finishHandoffRequest( associationId, handoffRequestResultMessage.taskId(), handoffRequestResultMessage.statusCode()); } if (handoffRequestResultMessage.activities().isEmpty()) { finishHandoffRequest( associationId, handoffRequestResultMessage.taskId(), HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK); return; } launchHandoffTask( associationId, handoffRequestResultMessage.taskId(), handoffRequestResultMessage.activities()); } } 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.startActivity(intent); finishHandoffRequest(associationId, taskId, HANDOFF_REQUEST_RESULT_SUCCESS); } private void finishHandoffRequest(int associationId, int taskId, int statusCode) { synchronized (mPendingCallbacks) { if (!mPendingCallbacks.containsKey(associationId)) { return; } Map<Integer, List<IHandoffRequestCallback>> pendingCallbacksForAssociation = mPendingCallbacks.get(associationId); if (!pendingCallbacksForAssociation.containsKey(taskId)) { return; } for (IHandoffRequestCallback callback : pendingCallbacksForAssociation.get(taskId)) { try { callback.onHandoffRequestFinished(associationId, taskId, statusCode); } catch (RemoteException e) { Slog.e(TAG, "Failed to notify callback of handoff request result", e); } } pendingCallbacksForAssociation.remove(taskId); } } } No newline at end of file
services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/TaskBroadcasterTest.java +4 −16 Original line number Diff line number Diff line Loading @@ -24,6 +24,8 @@ import static org.mockito.Mockito.never; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockContext; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockCompanionDeviceManager; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createAssociationInfo; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createRunningTaskInfo; Loading Loading @@ -77,11 +79,8 @@ public class TaskBroadcasterTest { @Mock private ActivityTaskManager mMockActivityTaskManager; @Mock private ICompanionDeviceManager mMockCompanionDeviceManagerService; private CompanionDeviceManager mCompanionDeviceManager; @Mock private ConnectedAssociationStore mMockConnectedAssociationStore; private TaskBroadcaster mTaskBroadcaster; Loading @@ -89,22 +88,11 @@ public class TaskBroadcasterTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); mMockContext = Mockito.spy( new ContextWrapper( InstrumentationRegistry .getInstrumentation() .getTargetContext())); // Setup fake services. mCompanionDeviceManager = new CompanionDeviceManager( mMockCompanionDeviceManagerService, mMockContext); mMockContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mMockContext); when(mMockContext.getSystemService(Context.ACTIVITY_TASK_SERVICE)) .thenReturn(mMockActivityTaskManager); when(mMockContext.getSystemService(Context.COMPANION_DEVICE_SERVICE)) .thenReturn(mCompanionDeviceManager); // Create TaskBroadcaster. mTaskBroadcaster = new TaskBroadcaster( Loading
services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/TaskContinuityTestUtils.java +58 −0 Original line number Diff line number Diff line Loading @@ -16,11 +16,69 @@ package com.android.server.companion.datatransfer.continuity; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import android.app.ActivityManager; import android.companion.AssociationInfo; import android.companion.CompanionDeviceManager; import android.companion.ICompanionDeviceManager; import android.content.Context; import android.content.ContextWrapper; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessage; import com.android.server.companion.datatransfer.continuity.messages.TaskContinuityMessageData; import androidx.test.platform.app.InstrumentationRegistry; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.io.IOException; public final class TaskContinuityTestUtils { public static Context createMockContext() { return Mockito.spy( new ContextWrapper( InstrumentationRegistry .getInstrumentation() .getTargetContext())); } public static ICompanionDeviceManager createMockCompanionDeviceManager(Context context) { ICompanionDeviceManager mockCompanionDeviceManagerService = mock(ICompanionDeviceManager.class); CompanionDeviceManager companionDeviceManager = new CompanionDeviceManager( mockCompanionDeviceManagerService, context); when(context.getSystemService(Context.COMPANION_DEVICE_SERVICE)) .thenReturn(companionDeviceManager); return mockCompanionDeviceManagerService; } public static TaskContinuityMessageData verifyMessageSent( ICompanionDeviceManager companionDeviceManagerService, int[] associationIds, int times) throws Exception { ArgumentCaptor<byte[]> messageCaptor = ArgumentCaptor.forClass(byte[].class); verify(companionDeviceManagerService, times(times)).sendMessage( eq(CompanionDeviceManager.MESSAGE_ONEWAY_TASK_CONTINUITY), messageCaptor.capture(), eq(associationIds)); TaskContinuityMessage taskContinuityMessage = new TaskContinuityMessage( messageCaptor.getValue()); return taskContinuityMessage.getData(); } public static ActivityManager.RunningTaskInfo createRunningTaskInfo( int taskId, String label, Loading
services/tests/servicestests/src/com/android/server/companion/datatransfer/continuity/handoff/OutboundHandoffRequestControllerTest.java 0 → 100644 +240 −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.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockContext; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.createMockCompanionDeviceManager; import static com.android.server.companion.datatransfer.continuity.TaskContinuityTestUtils.verifyMessageSent; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doNothing; import static org.mockito.ArgumentMatchers.any; import com.android.server.companion.datatransfer.continuity.messages.HandoffRequestMessage; 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.TaskContinuityMessageData; import android.app.HandoffActivityData; import android.content.Context; import android.content.ComponentName; import android.content.Intent; import android.companion.CompanionDeviceManager; import android.companion.ICompanionDeviceManager; import android.companion.datatransfer.continuity.IHandoffRequestCallback; import android.companion.datatransfer.continuity.TaskContinuityManager; import android.os.PersistableBundle; import android.os.RemoteException; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import java.util.ArrayList; import java.util.List; public class OutboundHandoffRequestControllerTest { private Context mContext; private ICompanionDeviceManager mMockCompanionDeviceManagerService; private OutboundHandoffRequestController mOutboundHandoffRequestController; @Before public void setUp() { mContext = createMockContext(); mMockCompanionDeviceManagerService = createMockCompanionDeviceManager(mContext); mOutboundHandoffRequestController = new OutboundHandoffRequestController(mContext); } @Test public void testRequestHandoff_success() throws Exception { int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callbackHolder = new HandoffRequestCallbackHolder(); // Request a handoff to a device. mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callbackHolder.callback); // Verify HandoffRequestMessage was sent. HandoffRequestMessage expectedHandoffRequestMessage = new HandoffRequestMessage(taskId); TaskContinuityMessageData actualMessageData = verifyMessageSent( mMockCompanionDeviceManagerService, new int[] {associationId}, 1); assertThat(actualMessageData).isInstanceOf(HandoffRequestMessage.class); assertThat(actualMessageData).isEqualTo(expectedHandoffRequestMessage); // Simulate a response message. ComponentName expectedComponentName = new ComponentName( "com.example.app", "com.example.app.Activity"); PersistableBundle expectedExtras = new PersistableBundle(); expectedExtras.putString("key", "value"); HandoffActivityData handoffActivityData = new HandoffActivityData.Builder(expectedComponentName) .setExtras(expectedExtras) .build(); doNothing().when(mContext).startActivity(any()); HandoffRequestResultMessage handoffRequestResultMessage = new HandoffRequestResultMessage( taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS, List.of(handoffActivityData)); mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, handoffRequestResultMessage); // Verify the intent was launched. ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(mContext, times(1)).startActivity(intentCaptor.capture()); Intent actualIntent = intentCaptor.getValue(); assertThat(actualIntent.getComponent()).isEqualTo(expectedComponentName); assertThat(actualIntent.getExtras().size()).isEqualTo(1); for (String key : actualIntent.getExtras().keySet()) { assertThat(actualIntent.getExtras().getString(key)) .isEqualTo(expectedExtras.getString(key)); } // Verify the callback was invoked. callbackHolder.verifyInvoked( associationId, taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS); } @Test public void testRequestHandoff_multipleTimes_onlySendsOneMessage() throws Exception { int associationId = 1; int taskId = 1; // Request handoff multiple times. HandoffRequestCallbackHolder firstCallback = new HandoffRequestCallbackHolder(); HandoffRequestCallbackHolder secondCallback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, firstCallback.callback); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, secondCallback.callback); // Verify HandoffRequestMessage was sent only once. TaskContinuityMessageData sentMessage = verifyMessageSent( mMockCompanionDeviceManagerService, new int[] {associationId}, 1); assertThat(sentMessage).isInstanceOf(HandoffRequestMessage.class); } @Test public void testRequestHandoff_failureStatusCode_returnsFailure() { // Request a handoff int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callback.callback); // Simulate a message failure int failureStatusCode = TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_TIMEOUT; mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, new HandoffRequestResultMessage(taskId, failureStatusCode, List.of())); // Verify the callback was invoked. callback.verifyInvoked( associationId, taskId, failureStatusCode); // Verify no intent was launched. verify(mContext, never()).startActivity(any()); } @Test public void testRequestHandoff_noActivities_returnsFailure() { // Request a handoff int associationId = 1; int taskId = 1; HandoffRequestCallbackHolder callback = new HandoffRequestCallbackHolder(); mOutboundHandoffRequestController.requestHandoff( associationId, taskId, callback.callback); // Return no data for this request. mOutboundHandoffRequestController.onHandoffRequestResultMessageReceived( associationId, new HandoffRequestResultMessage( taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_SUCCESS, List.of())); // Verify the callback was invoked. callback.verifyInvoked( associationId, taskId, TaskContinuityManager.HANDOFF_REQUEST_RESULT_FAILURE_NO_DATA_PROVIDED_BY_TASK); // Verify no intent was launched. verify(mContext, never()).startActivity(any()); } private final class HandoffRequestCallbackHolder { final List<Integer> receivedAssociationIds = new ArrayList<>(); final List<Integer> receivedTaskIds = new ArrayList<>(); final List<Integer> receivedResultCodes = new ArrayList<>(); final IHandoffRequestCallback callback = new IHandoffRequestCallback.Stub() { @Override public void onHandoffRequestFinished( int associationId, int remoteTaskId, int resultCode) throws RemoteException { receivedAssociationIds.add(associationId); receivedTaskIds.add(remoteTaskId); receivedResultCodes.add(resultCode); } }; void verifyInvoked(int associationId, int taskId, int resultCode) { assertThat(receivedAssociationIds).containsExactly(associationId); assertThat(receivedTaskIds).containsExactly(taskId); assertThat(receivedResultCodes).containsExactly(resultCode); } void verifyNotInvoked() { assertThat(receivedAssociationIds).isEmpty(); assertThat(receivedTaskIds).isEmpty(); assertThat(receivedResultCodes).isEmpty(); } } } No newline at end of file