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

Commit 0f092ff2 authored by Thomas Stuart's avatar Thomas Stuart Committed by Android (Google) Code Review
Browse files

Merge "add transactional call state verifier" into main

parents 30a34f27 32237a07
Loading
Loading
Loading
Loading
+53 −0
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.OutcomeReceiver;
import android.os.ParcelFileDescriptor;
import android.os.Parcelable;
import android.os.RemoteException;
@@ -43,6 +44,7 @@ import android.telecom.CallAttributes;
import android.telecom.CallAudioState;
import android.telecom.CallDiagnosticService;
import android.telecom.CallDiagnostics;
import android.telecom.CallException;
import android.telecom.CallerInfo;
import android.telecom.Conference;
import android.telecom.Connection;
@@ -73,6 +75,9 @@ import com.android.internal.util.Preconditions;
import com.android.server.telecom.stats.CallFailureCause;
import com.android.server.telecom.stats.CallStateChangedAtomWriter;
import com.android.server.telecom.ui.ToastFactory;
import com.android.server.telecom.voip.TransactionManager;
import com.android.server.telecom.voip.VerifyCallStateChangeTransaction;
import com.android.server.telecom.voip.VoipCallTransactionResult;

import java.io.IOException;
import java.text.SimpleDateFormat;
@@ -118,6 +123,24 @@ public class Call implements CreateConnectionResponse, EventManager.Loggable,

    private static final char NO_DTMF_TONE = '\0';


    /**
     * Listener for CallState changes which can be leveraged by a Transaction.
     */
    public interface CallStateListener {
        void onCallStateChanged(int newCallState);
    }

    public List<CallStateListener> mCallStateListeners = new ArrayList<>();

    public void addCallStateListener(CallStateListener newListener) {
        mCallStateListeners.add(newListener);
    }

    public boolean removeCallStateListener(CallStateListener newListener) {
        return mCallStateListeners.remove(newListener);
    }

    /**
     * Listener for events on the call.
     */
@@ -1328,6 +1351,10 @@ public class Call implements CreateConnectionResponse, EventManager.Loggable,
                Log.addEvent(this, event, stringData);
            }

            for (CallStateListener listener : mCallStateListeners) {
                listener.onCallStateChanged(newState);
            }

            mCallStateChangedAtomWriter
                    .setDisconnectCause(getDisconnectCause())
                    .setSelfManaged(isSelfManaged())
@@ -2898,11 +2925,16 @@ public class Call implements CreateConnectionResponse, EventManager.Loggable,
        hold(null /* reason */);
    }

    /**
     * This method requests the ConnectionService or TransactionalService hosting the call to put
     * the call on hold
     */
    public void hold(String reason) {
        if (mState == CallState.ACTIVE) {
            if (mTransactionalService != null) {
                mTransactionalService.onSetInactive(this);
            } else if (mConnectionService != null) {
                awaitCallStateChangeAndMaybeDisconnectCall(CallState.ON_HOLD, isSelfManaged(), "hold");
                mConnectionService.hold(this);
            } else {
                Log.e(this, new NullPointerException(),
@@ -2912,6 +2944,27 @@ public class Call implements CreateConnectionResponse, EventManager.Loggable,
        }
    }

    /**
     * helper that can be used for any callback that requests a call state change and wants to
     * verify the change
     */
    public void awaitCallStateChangeAndMaybeDisconnectCall(int targetCallState,
            boolean shouldDisconnectUponTimeout, String callingMethod) {
        TransactionManager tm = TransactionManager.getInstance();
        tm.addTransaction(new VerifyCallStateChangeTransaction(mCallsManager,
                this, targetCallState, shouldDisconnectUponTimeout), new OutcomeReceiver<>() {
            @Override
            public void onResult(VoipCallTransactionResult result) {
            }

            @Override
            public void onError(CallException e) {
                Log.i(this, "awaitCallStateChangeAndMaybeDisconnectCall: %s: onError"
                        + " due to CallException=[%s]", callingMethod, e);
            }
        });
    }

    /**
     * Releases the call from hold if it is currently active.
     */
+147 −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.telecom.voip;

import com.android.internal.annotations.VisibleForTesting;
import com.android.server.telecom.Call;
import com.android.server.telecom.CallsManager;

import android.telecom.DisconnectCause;
import android.telecom.Log;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;

/**
 * VerifyCallStateChangeTransaction is a transaction that verifies a CallState change and has
 * the ability to disconnect if the CallState is not changed within the timeout window.
 * <p>
 * Note: This transaction has a timeout of 2 seconds.
 */
public class VerifyCallStateChangeTransaction extends VoipCallTransaction {
    private static final String TAG = VerifyCallStateChangeTransaction.class.getSimpleName();
    public static final int FAILURE_CODE = 0;
    public static final int SUCCESS_CODE = 1;
    public static final int TIMEOUT_SECONDS = 2;
    private final Call mCall;
    private final CallsManager mCallsManager;
    private final int mTargetCallState;
    private final boolean mShouldDisconnectUponFailure;
    private final CompletableFuture<Integer> mCallStateOrTimeoutResult = new CompletableFuture<>();
    private final CompletableFuture<VoipCallTransactionResult> mTransactionResult =
            new CompletableFuture<>();

    @VisibleForTesting
    public Call.CallStateListener mCallStateListenerImpl = new Call.CallStateListener() {
        @Override
        public void onCallStateChanged(int newCallState) {
            Log.d(TAG, "newState=[%d], expectedState=[%d]", newCallState, mTargetCallState);
            if (newCallState == mTargetCallState) {
                mCallStateOrTimeoutResult.complete(SUCCESS_CODE);
            }
            // NOTE:: keep listening to the call state until the timeout is reached. It's possible
            // another call state is reached in between...
        }
    };

    public VerifyCallStateChangeTransaction(CallsManager callsManager, Call call,
            int targetCallState, boolean shouldDisconnectUponFailure) {
        super(callsManager.getLock());
        mCallsManager = callsManager;
        mCall = call;
        mTargetCallState = targetCallState;
        mShouldDisconnectUponFailure = shouldDisconnectUponFailure;
    }

    @Override
    public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
        Log.d(TAG, "processTransaction:");
        // It's possible the Call is already in the expected call state
        if (isNewCallStateTargetCallState()) {
            mTransactionResult.complete(
                    new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_SUCCEED,
                            TAG));
            return mTransactionResult;
        }
        initCallStateListenerOnTimeout();
        // At this point, the mCallStateOrTimeoutResult has been completed. There are 2 scenarios:
        // (1) newCallState == targetCallState --> the transaction is successful
        // (2) timeout is reached --> evaluate the current call state and complete the t accordingly
        // also need to do cleanup for the transaction
        evaluateCallStateUponChangeOrTimeout();

        return mTransactionResult;
    }

    private boolean isNewCallStateTargetCallState() {
        return mCall.getState() == mTargetCallState;
    }

    private void initCallStateListenerOnTimeout() {
        mCall.addCallStateListener(mCallStateListenerImpl);
        mCallStateOrTimeoutResult.completeOnTimeout(FAILURE_CODE, TIMEOUT_SECONDS,
                TimeUnit.SECONDS);
    }

    private void evaluateCallStateUponChangeOrTimeout() {
        mCallStateOrTimeoutResult.thenAcceptAsync((result) -> {
            Log.i(TAG, "processTransaction: thenAcceptAsync: result=[%s]", result);
            mCall.removeCallStateListener(mCallStateListenerImpl);
            if (isNewCallStateTargetCallState()) {
                mTransactionResult.complete(
                        new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_SUCCEED,
                                TAG));
            } else {
                maybeDisconnectCall();
                mTransactionResult.complete(
                        new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_FAILED,
                                TAG));
            }
        }).exceptionally(exception -> {
            Log.i(TAG, "hit exception=[%s] while completing future", exception);
            mTransactionResult.complete(
                    new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_FAILED,
                            TAG));
            return null;
        });
    }

    private void maybeDisconnectCall() {
        if (mShouldDisconnectUponFailure) {
            mCallsManager.markCallAsDisconnected(mCall,
                    new DisconnectCause(DisconnectCause.ERROR,
                            "did not hold in timeout window"));
            mCallsManager.markCallAsRemoved(mCall);
        }
    }

    @VisibleForTesting
    public CompletableFuture<Integer> getCallStateOrTimeoutResult() {
        return mCallStateOrTimeoutResult;
    }

    @VisibleForTesting
    public CompletableFuture<VoipCallTransactionResult> getTransactionResult() {
        return mTransactionResult;
    }

    @VisibleForTesting
    public Call.CallStateListener getCallStateListenerImpl() {
        return mCallStateListenerImpl;
    }
}
+79 −0
Original line number Diff line number Diff line
@@ -16,7 +16,11 @@

package com.android.server.telecom.tests;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -39,11 +43,14 @@ import android.telecom.CallAttributes;
import android.telecom.DisconnectCause;
import android.telecom.PhoneAccountHandle;

import androidx.test.filters.SmallTest;

import com.android.server.telecom.Call;
import com.android.server.telecom.CallState;
import com.android.server.telecom.CallerInfoLookupHelper;
import com.android.server.telecom.CallsManager;
import com.android.server.telecom.ClockProxy;
import com.android.server.telecom.ConnectionServiceWrapper;
import com.android.server.telecom.PhoneNumberUtilsAdapter;
import com.android.server.telecom.TelecomSystem;
import com.android.server.telecom.ui.ToastFactory;
@@ -53,6 +60,8 @@ import com.android.server.telecom.voip.IncomingCallTransaction;
import com.android.server.telecom.voip.OutgoingCallTransaction;
import com.android.server.telecom.voip.MaybeHoldCallForNewCallTransaction;
import com.android.server.telecom.voip.RequestNewActiveCallTransaction;
import com.android.server.telecom.voip.VerifyCallStateChangeTransaction;
import com.android.server.telecom.voip.VoipCallTransactionResult;

import org.junit.After;
import org.junit.Before;
@@ -62,6 +71,11 @@ import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;


public class TransactionTests extends TelecomTestCase {

@@ -250,6 +264,63 @@ public class TransactionTests extends TelecomTestCase {
                        isA(Boolean.class));
    }

    /**
     * This test verifies if the ConnectionService call is NOT transitioned to the desired call
     * state (within timeout period), Telecom will disconnect the call.
     */
    @SmallTest
    @Test
    public void testCallStateChangeTimesOut()
            throws ExecutionException, InterruptedException, TimeoutException {
        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(mCallsManager,
                mMockCall1, CallState.ON_HOLD, true);
        // WHEN
        setupHoldableCall();

        // simulate the transaction being processed and the CompletableFuture timing out
        t.processTransaction(null);
        CompletableFuture<Integer> timeoutFuture = t.getCallStateOrTimeoutResult();
        timeoutFuture.complete(VerifyCallStateChangeTransaction.FAILURE_CODE);

        // THEN
        verify(mMockCall1, times(1)).addCallStateListener(t.getCallStateListenerImpl());
        assertEquals(timeoutFuture.get().intValue(), VerifyCallStateChangeTransaction.FAILURE_CODE);
        assertEquals(VoipCallTransactionResult.RESULT_FAILED,
                t.getTransactionResult().get(2, TimeUnit.SECONDS).getResult());
        verify(mMockCall1, atLeastOnce()).removeCallStateListener(any());
        verify(mCallsManager, times(1)).markCallAsDisconnected(eq(mMockCall1), any());
        verify(mCallsManager, times(1)).markCallAsRemoved(eq(mMockCall1));
    }

    /**
     * This test verifies that when an application transitions a call to the requested state,
     * Telecom does not disconnect the call and transaction completes successfully.
     */
    @SmallTest
    @Test
    public void testCallStateIsSuccessfullyChanged()
            throws ExecutionException, InterruptedException, TimeoutException {
        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(mCallsManager,
                mMockCall1, CallState.ON_HOLD, true);
        // WHEN
        setupHoldableCall();

        // simulate the transaction being processed and the setOnHold() being called / state change
        t.processTransaction(null);
        t.getCallStateListenerImpl().onCallStateChanged(CallState.ON_HOLD);
        when(mMockCall1.getState()).thenReturn(CallState.ON_HOLD);

        // THEN
        verify(mMockCall1, times(1)).addCallStateListener(t.getCallStateListenerImpl());
        assertEquals(t.getCallStateOrTimeoutResult().get().intValue(),
                VerifyCallStateChangeTransaction.SUCCESS_CODE);
        assertEquals(VoipCallTransactionResult.RESULT_SUCCEED,
                t.getTransactionResult().get(2, TimeUnit.SECONDS).getResult());
        verify(mMockCall1, atLeastOnce()).removeCallStateListener(any());
        verify(mCallsManager, never()).markCallAsDisconnected(eq(mMockCall1), any());
        verify(mCallsManager, never()).markCallAsRemoved(eq(mMockCall1));
    }

    private Call createSpyCall(PhoneAccountHandle targetPhoneAccount, int initialState, String id) {
        when(mCallsManager.getCallerInfoLookupHelper()).thenReturn(mCallerInfoLookupHelper);

@@ -280,4 +351,12 @@ public class TransactionTests extends TelecomTestCase {

        return callSpy;
    }

    private void setupHoldableCall(){
        when(mMockCall1.getState()).thenReturn(CallState.ACTIVE);
        when(mMockCall1.getConnectionServiceWrapper()).thenReturn(
                mock(ConnectionServiceWrapper.class));
        doNothing().when(mMockCall1).addCallStateListener(any());
        doReturn(true).when(mMockCall1).removeCallStateListener(any());
    }
}
 No newline at end of file