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

Commit b0d76117 authored by Christine Franks's avatar Christine Franks
Browse files

Add cross device call metadata handling

Bug: 265959429
Test: atest FrameworksServicesTests:com.android.server.companion.datatransfer

Change-Id: Id41c6ff15aec32a6981a0d11c8a4aa71804f6bd5
parent 0dbe6ca5
Loading
Loading
Loading
Loading
+25 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.contextsync;

/** Callback for call metadata syncing. */
public abstract class CallMetadataSyncCallback {

    abstract void processCallControlAction(int crossDeviceCallId, int callControlAction);

    abstract void requestCrossDeviceSync(int userId);
}
+234 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.contextsync;

import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.telecom.Call;
import android.telecom.CallAudioState;
import android.telecom.VideoProfile;
import android.util.Slog;

import com.android.internal.annotations.VisibleForTesting;

import java.io.ByteArrayOutputStream;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;

/** Data holder for a telecom call and additional metadata. */
public class CrossDeviceCall {

    private static final String TAG = "CrossDeviceCall";

    private static final int APP_ICON_BITMAP_DIMENSION = 256;

    private static final AtomicLong sNextId = new AtomicLong(1);

    private final long mId;
    private final Call mCall;
    private String mCallingAppName;
    private byte[] mCallingAppIcon;
    private String mCallerDisplayName;
    private int mStatus = android.companion.Telecom.Call.UNKNOWN_STATUS;
    private boolean mIsMuted;
    private final Set<Integer> mControls = new HashSet<>();

    public CrossDeviceCall(PackageManager packageManager, Call call,
            CallAudioState callAudioState) {
        mId = sNextId.getAndIncrement();
        mCall = call;
        final String callingAppPackageName = call != null
                ? call.getDetails().getAccountHandle().getComponentName().getPackageName() : null;
        try {
            final ApplicationInfo applicationInfo = packageManager
                    .getApplicationInfo(callingAppPackageName,
                            PackageManager.ApplicationInfoFlags.of(0));
            mCallingAppName = packageManager.getApplicationLabel(applicationInfo).toString();
            mCallingAppIcon = renderDrawableToByteArray(
                    packageManager.getApplicationIcon(applicationInfo));
        } catch (PackageManager.NameNotFoundException e) {
            Slog.e(TAG, "Could not get application info for package " + callingAppPackageName, e);
        }
        mIsMuted = callAudioState != null && callAudioState.isMuted();
        if (call != null) {
            updateCallDetails(call.getDetails());
        }
    }

    private byte[] renderDrawableToByteArray(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            // Can't recycle the drawable's bitmap, so handle separately
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if (bitmap.getWidth() > APP_ICON_BITMAP_DIMENSION
                    || bitmap.getHeight() > APP_ICON_BITMAP_DIMENSION) {
                // Downscale, as the original drawable bitmap is too large.
                final Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap,
                        APP_ICON_BITMAP_DIMENSION, APP_ICON_BITMAP_DIMENSION, /* filter= */ true);
                final byte[] renderedBitmap = renderBitmapToByteArray(scaledBitmap);
                scaledBitmap.recycle();
                return renderedBitmap;
            }
            return renderBitmapToByteArray(bitmap);
        }
        final Bitmap bitmap = Bitmap.createBitmap(APP_ICON_BITMAP_DIMENSION,
                APP_ICON_BITMAP_DIMENSION,
                Bitmap.Config.ARGB_8888);
        try {
            final Canvas canvas = new Canvas(bitmap);
            drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight());
            drawable.draw(canvas);
        } finally {
            bitmap.recycle();
        }
        return renderBitmapToByteArray(bitmap);
    }

    private byte[] renderBitmapToByteArray(Bitmap bitmap) {
        final ByteArrayOutputStream baos = new ByteArrayOutputStream(bitmap.getByteCount());
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos);
        return baos.toByteArray();
    }

    /**
     * Update the mute state of this call. No-op if the call is not capable of being muted.
     *
     * @param isMuted true if the call should be muted, and false if the call should be unmuted.
     */
    public void updateMuted(boolean isMuted) {
        mIsMuted = isMuted;
        updateCallDetails(mCall.getDetails());
    }

    /**
     * Update the state of the call to be ringing silently if it is currently ringing. No-op if the
     * call is not
     * currently ringing.
     */
    public void updateSilencedIfRinging() {
        if (mStatus == android.companion.Telecom.Call.RINGING) {
            mStatus = android.companion.Telecom.Call.RINGING_SILENCED;
        }
        mControls.remove(android.companion.Telecom.Call.SILENCE);
    }

    @VisibleForTesting
    void updateCallDetails(Call.Details callDetails) {
        mCallerDisplayName = callDetails.getCallerDisplayName();
        mStatus = convertStateToStatus(callDetails.getState());
        mControls.clear();
        if (mStatus == android.companion.Telecom.Call.RINGING
                || mStatus == android.companion.Telecom.Call.RINGING_SILENCED) {
            mControls.add(android.companion.Telecom.Call.ACCEPT);
            mControls.add(android.companion.Telecom.Call.REJECT);
            if (mStatus == android.companion.Telecom.Call.RINGING) {
                mControls.add(android.companion.Telecom.Call.SILENCE);
            }
        }
        if (mStatus == android.companion.Telecom.Call.ONGOING
                || mStatus == android.companion.Telecom.Call.ON_HOLD) {
            mControls.add(android.companion.Telecom.Call.END);
            if (callDetails.can(Call.Details.CAPABILITY_HOLD)) {
                mControls.add(
                        mStatus == android.companion.Telecom.Call.ON_HOLD
                                ? android.companion.Telecom.Call.TAKE_OFF_HOLD
                                : android.companion.Telecom.Call.PUT_ON_HOLD);
            }
        }
        if (mStatus == android.companion.Telecom.Call.ONGOING && callDetails.can(
                Call.Details.CAPABILITY_MUTE)) {
            mControls.add(mIsMuted ? android.companion.Telecom.Call.UNMUTE
                    : android.companion.Telecom.Call.MUTE);
        }
    }

    private int convertStateToStatus(int callState) {
        switch (callState) {
            case Call.STATE_HOLDING:
                return android.companion.Telecom.Call.ON_HOLD;
            case Call.STATE_ACTIVE:
                return android.companion.Telecom.Call.ONGOING;
            case Call.STATE_RINGING:
                return android.companion.Telecom.Call.RINGING;
            case Call.STATE_NEW:
            case Call.STATE_DIALING:
            case Call.STATE_DISCONNECTED:
            case Call.STATE_SELECT_PHONE_ACCOUNT:
            case Call.STATE_CONNECTING:
            case Call.STATE_DISCONNECTING:
            case Call.STATE_PULLING_CALL:
            case Call.STATE_AUDIO_PROCESSING:
            case Call.STATE_SIMULATED_RINGING:
            default:
                return android.companion.Telecom.Call.UNKNOWN_STATUS;
        }
    }

    public long getId() {
        return mId;
    }

    public Call getCall() {
        return mCall;
    }

    public String getCallingAppName() {
        return mCallingAppName;
    }

    public byte[] getCallingAppIcon() {
        return mCallingAppIcon;
    }

    public String getReadableCallerId() {
        return mCallerDisplayName;
    }

    public int getStatus() {
        return mStatus;
    }

    public Set<Integer> getControls() {
        return mControls;
    }

    void doAccept() {
        mCall.answer(VideoProfile.STATE_AUDIO_ONLY);
    }

    void doReject() {
        if (mStatus == android.companion.Telecom.Call.RINGING) {
            mCall.reject(Call.REJECT_REASON_DECLINED);
        }
    }

    void doEnd() {
        mCall.disconnect();
    }

    void doPutOnHold() {
        mCall.hold();
    }

    void doTakeOffHold() {
        mCall.unhold();
    }
}
+183 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.contextsync;

import static com.google.common.truth.Truth.assertWithMessage;

import android.platform.test.annotations.Presubmit;
import android.telecom.Call;
import android.telecom.ParcelableCall;
import android.testing.AndroidTestingRunner;

import androidx.test.InstrumentationRegistry;

import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.Collections;
import java.util.Set;

@Presubmit
@RunWith(AndroidTestingRunner.class)
public class CrossDeviceCallTest {

    @Test
    public void updateCallDetails_uninitialized() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.UNKNOWN_STATUS);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls()).isEmpty();
    }

    @Test
    public void updateCallDetails_ringing() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_RINGING,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.RINGING);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.ACCEPT,
                        android.companion.Telecom.Call.REJECT,
                        android.companion.Telecom.Call.SILENCE));
    }

    @Test
    public void updateCallDetails_ongoing() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_ACTIVE,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ONGOING);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.MUTE,
                        android.companion.Telecom.Call.PUT_ON_HOLD));
    }

    @Test
    public void updateCallDetails_holding() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_HOLDING,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ON_HOLD);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.TAKE_OFF_HOLD));
    }

    @Test
    public void updateCallDetails_cannotHold() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(
                createCallDetails(Call.STATE_ACTIVE, Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ONGOING);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.MUTE));
    }

    @Test
    public void updateCallDetails_cannotMute() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(
                createCallDetails(Call.STATE_ACTIVE, Call.Details.CAPABILITY_HOLD));
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ONGOING);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.PUT_ON_HOLD));
    }

    @Test
    public void updateCallDetails_transitionRingingToOngoing() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_RINGING,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status for ringing state").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.RINGING);
        assertWithMessage("Wrong controls for ringing state").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.ACCEPT,
                        android.companion.Telecom.Call.REJECT,
                        android.companion.Telecom.Call.SILENCE));
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_ACTIVE,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        assertWithMessage("Wrong status for active state").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ONGOING);
        assertWithMessage("Wrong controls for active state").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.MUTE,
                        android.companion.Telecom.Call.PUT_ON_HOLD));
    }

    @Test
    public void updateSilencedIfRinging_ringing_silenced() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_RINGING,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        crossDeviceCall.updateSilencedIfRinging();
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.RINGING_SILENCED);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.ACCEPT,
                        android.companion.Telecom.Call.REJECT));
    }

    @Test
    public void updateSilencedIfRinging_notRinging_notSilenced() {
        final CrossDeviceCall crossDeviceCall = new CrossDeviceCall(
                InstrumentationRegistry.getTargetContext().getPackageManager(), /* call= */
                null, /* callAudioState= */ null);
        crossDeviceCall.updateCallDetails(createCallDetails(Call.STATE_ACTIVE,
                Call.Details.CAPABILITY_HOLD | Call.Details.CAPABILITY_MUTE));
        crossDeviceCall.updateSilencedIfRinging();
        assertWithMessage("Wrong status").that(crossDeviceCall.getStatus())
                .isEqualTo(android.companion.Telecom.Call.ONGOING);
        assertWithMessage("Wrong controls").that(crossDeviceCall.getControls())
                .isEqualTo(Set.of(android.companion.Telecom.Call.END,
                        android.companion.Telecom.Call.MUTE,
                        android.companion.Telecom.Call.PUT_ON_HOLD));
    }

    private Call.Details createCallDetails(int state, int capabilities) {
        final ParcelableCall.ParcelableCallBuilder parcelableCallBuilder =
                new ParcelableCall.ParcelableCallBuilder();
        parcelableCallBuilder.setCallerDisplayName("name");
        parcelableCallBuilder.setCapabilities(capabilities);
        parcelableCallBuilder.setState(state);
        parcelableCallBuilder.setConferenceableCallIds(Collections.emptyList());
        return Call.Details.createFromParcelableCall(parcelableCallBuilder.createParcelableCall());
    }
}