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

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

Merge "[3/N][Requesting Handoff from App] Create OutboundHandoffRequestController" into main

parents 12d638dd d665b98a
Loading
Loading
Loading
Loading
+13 −2
Original line number Diff line number Diff line
@@ -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;
@@ -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;
@@ -63,6 +66,7 @@ public final class TaskContinuityManagerService extends SystemService {

        mTaskContinuityMessageReceiver = new TaskContinuityMessageReceiver(context);
        mRemoteTaskStore = new RemoteTaskStore(mConnectedAssociationStore);
        mOutboundHandoffRequestController = new OutboundHandoffRequestController(context);
    }

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

@@ -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;
+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
+4 −16
Original line number Diff line number Diff line
@@ -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;

@@ -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;
@@ -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(
+58 −0
Original line number Diff line number Diff line
@@ -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,
+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