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

Commit 153d347c authored by Christine Franks's avatar Christine Franks Committed by Android (Google) Code Review
Browse files

Merge "Add cross device call metadata handling"

parents 9d97d8be b0d76117
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());
    }
}