Moves VerifyCallStateChangeTransaction to use existing timeout

Adds the ability for a VoipCallTransaction to specify a different
timeout from the default and brings VerifyCallStateChangeTransaction
impl to use that timeout instead of redefining a new timeout
again in the subclass.

Bug: 327038818
Test: atest TelecomUnitTests
Change-Id: Ic7ae1ca2892f071a5ab5d38fee46a95f060bbbd8
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index ed72a3f..cdf7cd9 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -3053,16 +3053,24 @@
     public void awaitCallStateChangeAndMaybeDisconnectCall(int targetCallState,
             boolean shouldDisconnectUponTimeout, String callingMethod) {
         TransactionManager tm = TransactionManager.getInstance();
-        tm.addTransaction(new VerifyCallStateChangeTransaction(mCallsManager,
-                this, targetCallState, shouldDisconnectUponTimeout), new OutcomeReceiver<>() {
+        tm.addTransaction(new VerifyCallStateChangeTransaction(mCallsManager.getLock(),
+                this, targetCallState), new OutcomeReceiver<>() {
             @Override
             public void onResult(VoipCallTransactionResult result) {
+                Log.i(this, "awaitCallStateChangeAndMaybeDisconnectCall: %s: onResult:"
+                        + " due to CallException=[%s]", callingMethod, result);
             }
 
             @Override
             public void onError(CallException e) {
                 Log.i(this, "awaitCallStateChangeAndMaybeDisconnectCall: %s: onError"
                         + " due to CallException=[%s]", callingMethod, e);
+                if (shouldDisconnectUponTimeout) {
+                    mCallsManager.markCallAsDisconnected(Call.this,
+                            new DisconnectCause(DisconnectCause.ERROR,
+                                    "did not hold in timeout window"));
+                    mCallsManager.markCallAsRemoved(Call.this);
+                }
             }
         });
     }
diff --git a/src/com/android/server/telecom/voip/CallEventCallbackAckTransaction.java b/src/com/android/server/telecom/voip/CallEventCallbackAckTransaction.java
index 93d9836..9e140a7 100644
--- a/src/com/android/server/telecom/voip/CallEventCallbackAckTransaction.java
+++ b/src/com/android/server/telecom/voip/CallEventCallbackAckTransaction.java
@@ -125,7 +125,7 @@
 
         try {
             // wait for the client to ack that CallEventCallback
-            boolean success = latch.await(VoipCallTransaction.TIMEOUT_LIMIT, TimeUnit.MILLISECONDS);
+            boolean success = latch.await(mTransactionTimeoutMs, TimeUnit.MILLISECONDS);
             if (!success) {
                 // client send onError and failed to complete transaction
                 Log.i(TAG, String.format("CallEventCallbackAckTransaction:"
diff --git a/src/com/android/server/telecom/voip/ParallelTransaction.java b/src/com/android/server/telecom/voip/ParallelTransaction.java
index 621892a..79a940b 100644
--- a/src/com/android/server/telecom/voip/ParallelTransaction.java
+++ b/src/com/android/server/telecom/voip/ParallelTransaction.java
@@ -33,78 +33,62 @@
     }
 
     @Override
-    public void start() {
-        if (mStats != null) mStats.markStarted();
-        // post timeout work
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
-        future.thenApplyAsync((x) -> {
-            if (mCompleted.getAndSet(true)) {
-                return null;
-            }
-            if (mCompleteListener != null) {
-                mCompleteListener.onTransactionTimeout(mTransactionName);
-            }
-            timeout();
-            return null;
-        }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
-                + ".s", mLock));
+    public void processTransactions() {
+        if (mSubTransactions == null || mSubTransactions.isEmpty()) {
+            scheduleTransaction();
+            return;
+        }
+        TransactionManager.TransactionCompleteListener subTransactionListener =
+                new TransactionManager.TransactionCompleteListener() {
+                    private final AtomicInteger mCount = new AtomicInteger(mSubTransactions.size());
 
-        if (mSubTransactions != null && mSubTransactions.size() > 0) {
-            TransactionManager.TransactionCompleteListener subTransactionListener =
-                    new TransactionManager.TransactionCompleteListener() {
-                        private final AtomicInteger mCount = new AtomicInteger(mSubTransactions.size());
-
-                        @Override
-                        public void onTransactionCompleted(VoipCallTransactionResult result,
-                                String transactionName) {
-                            if (result.getResult() != VoipCallTransactionResult.RESULT_SUCCEED) {
-                                CompletableFuture.completedFuture(null).thenApplyAsync(
-                                        (x) -> {
-                                            VoipCallTransactionResult mainResult =
-                                                    new VoipCallTransactionResult(
-                                                            VoipCallTransactionResult.RESULT_FAILED,
-                                                            String.format(
-                                                                    "sub transaction %s failed",
-                                                                    transactionName));
-                                            mCompleteListener.onTransactionCompleted(mainResult,
-                                                    mTransactionName);
-                                            finish(mainResult);
-                                            return null;
-                                        }, new LoggedHandlerExecutor(mHandler,
-                                                mTransactionName + "@" + hashCode()
-                                                        + ".oTC", mLock));
-                            } else {
-                                if (mCount.decrementAndGet() == 0) {
-                                    scheduleTransaction();
-                                }
-                            }
-                        }
-
-                        @Override
-                        public void onTransactionTimeout(String transactionName) {
+                    @Override
+                    public void onTransactionCompleted(VoipCallTransactionResult result,
+                            String transactionName) {
+                        if (result.getResult() != VoipCallTransactionResult.RESULT_SUCCEED) {
                             CompletableFuture.completedFuture(null).thenApplyAsync(
                                     (x) -> {
                                         VoipCallTransactionResult mainResult =
                                                 new VoipCallTransactionResult(
-                                                VoipCallTransactionResult.RESULT_FAILED,
-                                                String.format("sub transaction %s timed out",
-                                                        transactionName));
+                                                        VoipCallTransactionResult.RESULT_FAILED,
+                                                        String.format(
+                                                                "sub transaction %s failed",
+                                                                transactionName));
+                                        finish(mainResult);
                                         mCompleteListener.onTransactionCompleted(mainResult,
                                                 mTransactionName);
-                                        finish(mainResult);
                                         return null;
                                     }, new LoggedHandlerExecutor(mHandler,
                                             mTransactionName + "@" + hashCode()
-                                                    + ".oTT", mLock));
+                                                    + ".oTC", mLock));
+                        } else {
+                            if (mCount.decrementAndGet() == 0) {
+                                scheduleTransaction();
+                            }
                         }
-                    };
-            for (VoipCallTransaction transaction : mSubTransactions) {
-                transaction.setCompleteListener(subTransactionListener);
-                transaction.start();
-            }
-        } else {
-            scheduleTransaction();
+                    }
+
+                    @Override
+                    public void onTransactionTimeout(String transactionName) {
+                        CompletableFuture.completedFuture(null).thenApplyAsync(
+                                (x) -> {
+                                    VoipCallTransactionResult mainResult =
+                                            new VoipCallTransactionResult(
+                                            VoipCallTransactionResult.RESULT_FAILED,
+                                            String.format("sub transaction %s timed out",
+                                                    transactionName));
+                                    finish(mainResult);
+                                    mCompleteListener.onTransactionCompleted(mainResult,
+                                            mTransactionName);
+                                    return null;
+                                }, new LoggedHandlerExecutor(mHandler,
+                                        mTransactionName + "@" + hashCode()
+                                                + ".oTT", mLock));
+                    }
+                };
+        for (VoipCallTransaction transaction : mSubTransactions) {
+            transaction.setCompleteListener(subTransactionListener);
+            transaction.start();
         }
     }
 }
diff --git a/src/com/android/server/telecom/voip/SerialTransaction.java b/src/com/android/server/telecom/voip/SerialTransaction.java
index 7d5a178..55d2065 100644
--- a/src/com/android/server/telecom/voip/SerialTransaction.java
+++ b/src/com/android/server/telecom/voip/SerialTransaction.java
@@ -37,86 +37,71 @@
     }
 
     @Override
-    public void start() {
-        if (mStats != null) mStats.markStarted();
-        // post timeout work
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
-        future.thenApplyAsync((x) -> {
-            if (mCompleted.getAndSet(true)) {
-                return null;
-            }
-            if (mCompleteListener != null) {
-                mCompleteListener.onTransactionTimeout(mTransactionName);
-            }
-            timeout();
-            return null;
-        }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
-                + ".s", mLock));
+    public void processTransactions() {
+        if (mSubTransactions == null || mSubTransactions.isEmpty()) {
+            scheduleTransaction();
+            return;
+        }
+        TransactionManager.TransactionCompleteListener subTransactionListener =
+                new TransactionManager.TransactionCompleteListener() {
+                    private final AtomicInteger mTransactionIndex = new AtomicInteger(0);
 
-        if (mSubTransactions != null && mSubTransactions.size() > 0) {
-            TransactionManager.TransactionCompleteListener subTransactionListener =
-                    new TransactionManager.TransactionCompleteListener() {
-                        private final AtomicInteger mTransactionIndex = new AtomicInteger(0);
-
-                        @Override
-                        public void onTransactionCompleted(VoipCallTransactionResult result,
-                                String transactionName) {
-                            if (result.getResult() != VoipCallTransactionResult.RESULT_SUCCEED) {
-                                handleTransactionFailure();
-                                CompletableFuture.completedFuture(null).thenApplyAsync(
-                                        (x) -> {
-                                            VoipCallTransactionResult mainResult =
-                                                    new VoipCallTransactionResult(
-                                                            VoipCallTransactionResult.RESULT_FAILED,
-                                                            String.format(
-                                                                    "sub transaction %s failed",
-                                                                    transactionName));
-                                            mCompleteListener.onTransactionCompleted(mainResult,
-                                                    mTransactionName);
-                                            finish(mainResult);
-                                            return null;
-                                        }, new LoggedHandlerExecutor(mHandler,
-                                                mTransactionName + "@" + hashCode()
-                                                        + ".oTC", mLock));
-                            } else {
-                                int currTransactionIndex = mTransactionIndex.incrementAndGet();
-                                if (currTransactionIndex < mSubTransactions.size()) {
-                                    VoipCallTransaction transaction = mSubTransactions.get(
-                                            currTransactionIndex);
-                                    transaction.setCompleteListener(this);
-                                    transaction.start();
-                                } else {
-                                    scheduleTransaction();
-                                }
-                            }
-                        }
-
-                        @Override
-                        public void onTransactionTimeout(String transactionName) {
+                    @Override
+                    public void onTransactionCompleted(VoipCallTransactionResult result,
+                            String transactionName) {
+                        if (result.getResult() != VoipCallTransactionResult.RESULT_SUCCEED) {
                             handleTransactionFailure();
                             CompletableFuture.completedFuture(null).thenApplyAsync(
                                     (x) -> {
                                         VoipCallTransactionResult mainResult =
                                                 new VoipCallTransactionResult(
-                                                VoipCallTransactionResult.RESULT_FAILED,
-                                                String.format("sub transaction %s timed out",
-                                                        transactionName));
+                                                        VoipCallTransactionResult.RESULT_FAILED,
+                                                        String.format(
+                                                                "sub transaction %s failed",
+                                                                transactionName));
+                                        finish(mainResult);
                                         mCompleteListener.onTransactionCompleted(mainResult,
                                                 mTransactionName);
-                                        finish(mainResult);
                                         return null;
                                     }, new LoggedHandlerExecutor(mHandler,
                                             mTransactionName + "@" + hashCode()
-                                                    + ".oTT", mLock));
+                                                    + ".oTC", mLock));
+                        } else {
+                            int currTransactionIndex = mTransactionIndex.incrementAndGet();
+                            if (currTransactionIndex < mSubTransactions.size()) {
+                                VoipCallTransaction transaction = mSubTransactions.get(
+                                        currTransactionIndex);
+                                transaction.setCompleteListener(this);
+                                transaction.start();
+                            } else {
+                                scheduleTransaction();
+                            }
                         }
-                    };
-            VoipCallTransaction transaction = mSubTransactions.get(0);
-            transaction.setCompleteListener(subTransactionListener);
-            transaction.start();
-        } else {
-            scheduleTransaction();
-        }
+                    }
+
+                    @Override
+                    public void onTransactionTimeout(String transactionName) {
+                        handleTransactionFailure();
+                        CompletableFuture.completedFuture(null).thenApplyAsync(
+                                (x) -> {
+                                    VoipCallTransactionResult mainResult =
+                                            new VoipCallTransactionResult(
+                                            VoipCallTransactionResult.RESULT_FAILED,
+                                            String.format("sub transaction %s timed out",
+                                                    transactionName));
+                                    finish(mainResult);
+                                    mCompleteListener.onTransactionCompleted(mainResult,
+                                            mTransactionName);
+                                    return null;
+                                }, new LoggedHandlerExecutor(mHandler,
+                                        mTransactionName + "@" + hashCode()
+                                                + ".oTT", mLock));
+                    }
+                };
+        VoipCallTransaction transaction = mSubTransactions.get(0);
+        transaction.setCompleteListener(subTransactionListener);
+        transaction.start();
+
     }
 
     public void handleTransactionFailure() {}
diff --git a/src/com/android/server/telecom/voip/VerifyCallStateChangeTransaction.java b/src/com/android/server/telecom/voip/VerifyCallStateChangeTransaction.java
index b17dedd..5de4b1d 100644
--- a/src/com/android/server/telecom/voip/VerifyCallStateChangeTransaction.java
+++ b/src/com/android/server/telecom/voip/VerifyCallStateChangeTransaction.java
@@ -18,14 +18,12 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.telecom.Call;
-import com.android.server.telecom.CallsManager;
+import com.android.server.telecom.TelecomSystem;
 
-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
@@ -35,37 +33,30 @@
  */
 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 static final long CALL_STATE_TIMEOUT_MILLISECONDS = 2000L;
     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() {
+    private final 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);
+                mTransactionResult.complete(new VoipCallTransactionResult(
+                        VoipCallTransactionResult.RESULT_SUCCEED, TAG));
             }
             // 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;
+    public VerifyCallStateChangeTransaction(TelecomSystem.SyncRoot lock,  Call call,
+            int targetCallState) {
+        super(lock, CALL_STATE_TIMEOUT_MILLISECONDS);
         mCall = call;
         mTargetCallState = targetCallState;
-        mShouldDisconnectUponFailure = shouldDisconnectUponFailure;
     }
 
     @Override
@@ -73,68 +64,23 @@
         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));
+            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();
-
+        mCall.addCallStateListener(mCallStateListenerImpl);
         return mTransactionResult;
     }
 
+    @Override
+    public void finishTransaction() {
+        mCall.removeCallStateListener(mCallStateListenerImpl);
+    }
+
     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;
diff --git a/src/com/android/server/telecom/voip/VoipCallTransaction.java b/src/com/android/server/telecom/voip/VoipCallTransaction.java
index 3c91158..ceb8d55 100644
--- a/src/com/android/server/telecom/voip/VoipCallTransaction.java
+++ b/src/com/android/server/telecom/voip/VoipCallTransaction.java
@@ -20,6 +20,7 @@
 import android.os.HandlerThread;
 import android.telecom.Log;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.telecom.LoggedHandlerExecutor;
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.flags.Flags;
@@ -34,7 +35,7 @@
 
 public class VoipCallTransaction {
     //TODO: add log events
-    protected static final long TIMEOUT_LIMIT = 5000L;
+    private static final long DEFAULT_TRANSACTION_TIMEOUT_MS = 5000L;
 
     /**
      * Tracks stats about a transaction for logging purposes.
@@ -129,58 +130,80 @@
 
     protected final AtomicBoolean mCompleted = new AtomicBoolean(false);
     protected final String mTransactionName = this.getClass().getSimpleName();
-    private HandlerThread mHandlerThread;
-    protected Handler mHandler;
+    private final HandlerThread mHandlerThread;
+    protected final Handler mHandler;
     protected TransactionManager.TransactionCompleteListener mCompleteListener;
-    protected List<VoipCallTransaction> mSubTransactions;
-    protected TelecomSystem.SyncRoot mLock;
+    protected final List<VoipCallTransaction> mSubTransactions;
+    protected final TelecomSystem.SyncRoot mLock;
+    protected final long mTransactionTimeoutMs;
     protected final Stats mStats;
 
     public VoipCallTransaction(
-            List<VoipCallTransaction> subTransactions, TelecomSystem.SyncRoot lock) {
+            List<VoipCallTransaction> subTransactions, TelecomSystem.SyncRoot lock,
+            long timeoutMs) {
         mSubTransactions = subTransactions;
         mHandlerThread = new HandlerThread(this.toString());
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
         mLock = lock;
+        mTransactionTimeoutMs = timeoutMs;
         mStats = Flags.enableCallSequencing() ? new Stats() : null;
     }
 
-    public VoipCallTransaction(TelecomSystem.SyncRoot lock) {
-        this(null /** mSubTransactions */, lock);
+    public VoipCallTransaction(List<VoipCallTransaction> subTransactions,
+            TelecomSystem.SyncRoot lock) {
+        this(subTransactions, lock, DEFAULT_TRANSACTION_TIMEOUT_MS);
+    }
+    public VoipCallTransaction(TelecomSystem.SyncRoot lock, long timeoutMs) {
+        this(null /* mSubTransactions */, lock, timeoutMs);
     }
 
-    public void start() {
+    public VoipCallTransaction(TelecomSystem.SyncRoot lock) {
+        this(null /* mSubTransactions */, lock);
+    }
+
+    public final void start() {
         if (mStats != null) mStats.markStarted();
         // post timeout work
         CompletableFuture<Void> future = new CompletableFuture<>();
-        mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
+        mHandler.postDelayed(() -> future.complete(null), mTransactionTimeoutMs);
         future.thenApplyAsync((x) -> {
-            if (mCompleted.getAndSet(true)) {
-                return null;
-            }
-            if (mCompleteListener != null) {
-                mCompleteListener.onTransactionTimeout(mTransactionName);
-            }
             timeout();
             return null;
         }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
                 + ".s", mLock));
 
+        processTransactions();
+    }
+
+    /**
+     * By default, this processes this transaction. For VoipCallTransactions with sub-transactions,
+     * this implementation should be overwritten to handle also processing sub-transactions.
+     */
+    protected void processTransactions() {
         scheduleTransaction();
     }
 
-    protected void scheduleTransaction() {
+    /**
+     * This method is called when the transaction has finished either successfully or exceptionally.
+     * VoipCallTransactions that are extending this class should override this method to clean up
+     * any leftover state.
+     */
+    protected void finishTransaction() {
+
+    }
+
+    protected final void scheduleTransaction() {
         LoggedHandlerExecutor executor = new LoggedHandlerExecutor(mHandler,
                 mTransactionName + "@" + hashCode() + ".pT", mLock);
         CompletableFuture<Void> future = CompletableFuture.completedFuture(null);
         future.thenComposeAsync(this::processTransaction, executor)
                 .thenApplyAsync((Function<VoipCallTransactionResult, Void>) result -> {
                     mCompleted.set(true);
+                    finish(result);
                     if (mCompleteListener != null) {
                         mCompleteListener.onTransactionCompleted(result, mTransactionName);
                     }
-                    finish(result);
                     return null;
                     }, executor)
                 .exceptionallyAsync((throwable -> {
@@ -189,25 +212,38 @@
                 }), executor);
     }
 
-    public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
+    protected CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
         return CompletableFuture.completedFuture(
                 new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_SUCCEED, null));
     }
 
-    public void setCompleteListener(TransactionManager.TransactionCompleteListener listener) {
+    public final void setCompleteListener(TransactionManager.TransactionCompleteListener listener) {
         mCompleteListener = listener;
     }
 
-    public void timeout() {
+    @VisibleForTesting
+    public final void timeout() {
+        if (mCompleted.getAndSet(true)) {
+            return;
+        }
         finish(true, null);
+        if (mCompleteListener != null) {
+            mCompleteListener.onTransactionTimeout(mTransactionName);
+        }
     }
 
-    public void finish(VoipCallTransactionResult result) {
+    @VisibleForTesting
+    public final Handler getHandler() {
+        return mHandler;
+    }
+
+    public final void finish(VoipCallTransactionResult result) {
         finish(false, result);
     }
 
-    public void finish(boolean isTimedOut, VoipCallTransactionResult result) {
+    private void finish(boolean isTimedOut, VoipCallTransactionResult result) {
         if (mStats != null) mStats.markComplete(isTimedOut, result);
+        finishTransaction();
         // finish all sub transactions
         if (mSubTransactions != null && !mSubTransactions.isEmpty()) {
             mSubTransactions.forEach( t -> t.finish(isTimedOut, result));
@@ -218,7 +254,7 @@
     /**
      * @return Stats related to this transaction if stats are enabled, null otherwise.
      */
-    public Stats getStats() {
+    public final Stats getStats() {
         return mStats;
     }
 }
diff --git a/tests/src/com/android/server/telecom/tests/TransactionTests.java b/tests/src/com/android/server/telecom/tests/TransactionTests.java
index e58c6c4..b5a0c26 100644
--- a/tests/src/com/android/server/telecom/tests/TransactionTests.java
+++ b/tests/src/com/android/server/telecom/tests/TransactionTests.java
@@ -17,11 +17,13 @@
 package com.android.server.telecom.tests;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 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.timeout;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.isA;
@@ -61,6 +63,7 @@
 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.TransactionManager;
 import com.android.server.telecom.voip.VerifyCallStateChangeTransaction;
 import com.android.server.telecom.voip.VoipCallTransactionResult;
 
@@ -271,27 +274,24 @@
      */
     @SmallTest
     @Test
-    public void testCallStateChangeTimesOut()
-            throws ExecutionException, InterruptedException, TimeoutException {
+    public void testCallStateChangeTimesOut() {
         when(mFeatureFlags.transactionalCsVerifier()).thenReturn(true);
-        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(mCallsManager,
-                mMockCall1, CallState.ON_HOLD, true);
+        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(
+                mLock, mMockCall1, CallState.ON_HOLD);
+        TransactionManager.TransactionCompleteListener listener =
+                mock(TransactionManager.TransactionCompleteListener.class);
+        t.setCompleteListener(listener);
         // 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);
+        t.timeout();
 
         // 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(listener).onTransactionTimeout(anyString());
         verify(mMockCall1, atLeastOnce()).removeCallStateListener(any());
-        verify(mCallsManager, times(1)).markCallAsDisconnected(eq(mMockCall1), any());
-        verify(mCallsManager, times(1)).markCallAsRemoved(eq(mMockCall1));
     }
 
     /**
@@ -303,25 +303,23 @@
     public void testCallStateIsSuccessfullyChanged()
             throws ExecutionException, InterruptedException, TimeoutException {
         when(mFeatureFlags.transactionalCsVerifier()).thenReturn(true);
-        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(mCallsManager,
-                mMockCall1, CallState.ON_HOLD, true);
+        VerifyCallStateChangeTransaction t = new VerifyCallStateChangeTransaction(
+                mLock, mMockCall1, CallState.ON_HOLD);
         // WHEN
         setupHoldableCall();
 
         // simulate the transaction being processed and the setOnHold() being called / state change
         t.processTransaction(null);
+        doReturn(CallState.ON_HOLD).when(mMockCall1).getState();
         t.getCallStateListenerImpl().onCallStateChanged(CallState.ON_HOLD);
-        when(mMockCall1.getState()).thenReturn(CallState.ON_HOLD);
+        t.finish(null);
+
 
         // 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) {
diff --git a/tests/src/com/android/server/telecom/tests/VoipCallTransactionTest.java b/tests/src/com/android/server/telecom/tests/VoipCallTransactionTest.java
index b7848a2..30cfc2e 100644
--- a/tests/src/com/android/server/telecom/tests/VoipCallTransactionTest.java
+++ b/tests/src/com/android/server/telecom/tests/VoipCallTransactionTest.java
@@ -61,6 +61,7 @@
         private long mSleepTime;
         private String mName;
         private int mType;
+        public boolean isFinished = false;
 
         public TestVoipCallTransaction(String name, long sleepTime, int type) {
             super(VoipCallTransactionTest.this.mLock);
@@ -96,6 +97,11 @@
             }, mSleepTime);
             return resultFuture;
         }
+
+        @Override
+        public void finishTransaction() {
+            isFinished = true;
+        }
     }
 
     @Override
@@ -109,7 +115,6 @@
     @Override
     @After
     public void tearDown() throws Exception {
-        Log.i("Grace", mLog.toString());
         mTransactionManager.clear();
         super.tearDown();
     }
@@ -119,11 +124,11 @@
     public void testSerialTransactionSuccess()
             throws ExecutionException, InterruptedException, TimeoutException {
         List<VoipCallTransaction> subTransactions = new ArrayList<>();
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
+        TestVoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
                 TestVoipCallTransaction.SUCCESS);
         subTransactions.add(t1);
         subTransactions.add(t2);
@@ -137,6 +142,7 @@
         assertEquals(VoipCallTransactionResult.RESULT_SUCCEED,
                 resultFuture.get(5000L, TimeUnit.MILLISECONDS).getResult());
         assertEquals(expectedLog, mLog.toString());
+        verifyTransactionsFinished(t1, t2, t3);
     }
 
     @SmallTest
@@ -144,11 +150,11 @@
     public void testSerialTransactionFailed()
             throws ExecutionException, InterruptedException, TimeoutException {
         List<VoipCallTransaction> subTransactions = new ArrayList<>();
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
                 TestVoipCallTransaction.FAILED);
-        VoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
+        TestVoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
                 TestVoipCallTransaction.SUCCESS);
         subTransactions.add(t1);
         subTransactions.add(t2);
@@ -171,6 +177,7 @@
         exceptionFuture.get(5000L, TimeUnit.MILLISECONDS);
         String expectedLog = "t1 success;\nt2 failed;\n";
         assertEquals(expectedLog, mLog.toString());
+        verifyTransactionsFinished(t1, t2, t3);
     }
 
     @SmallTest
@@ -178,11 +185,11 @@
     public void testParallelTransactionSuccess()
             throws ExecutionException, InterruptedException, TimeoutException {
         List<VoipCallTransaction> subTransactions = new ArrayList<>();
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 500L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 500L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t3 = new TestVoipCallTransaction("t3", 200L,
+        TestVoipCallTransaction t3 = new TestVoipCallTransaction("t3", 200L,
                 TestVoipCallTransaction.SUCCESS);
         subTransactions.add(t1);
         subTransactions.add(t2);
@@ -198,6 +205,7 @@
         assertTrue(log.contains("t1 success;\n"));
         assertTrue(log.contains("t2 success;\n"));
         assertTrue(log.contains("t3 success;\n"));
+        verifyTransactionsFinished(t1, t2, t3);
     }
 
     @SmallTest
@@ -205,11 +213,11 @@
     public void testParallelTransactionFailed()
             throws ExecutionException, InterruptedException, TimeoutException {
         List<VoipCallTransaction> subTransactions = new ArrayList<>();
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 500L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 500L,
                 TestVoipCallTransaction.FAILED);
-        VoipCallTransaction t3 = new TestVoipCallTransaction("t3", 200L,
+        TestVoipCallTransaction t3 = new TestVoipCallTransaction("t3", 200L,
                 TestVoipCallTransaction.SUCCESS);
         subTransactions.add(t1);
         subTransactions.add(t2);
@@ -231,13 +239,14 @@
                 outcomeReceiver);
         exceptionFuture.get(5000L, TimeUnit.MILLISECONDS);
         assertTrue(mLog.toString().contains("t2 failed;\n"));
+        verifyTransactionsFinished(t1, t2, t3);
     }
 
     @SmallTest
     @Test
     public void testTransactionTimeout()
             throws ExecutionException, InterruptedException, TimeoutException {
-        VoipCallTransaction t = new TestVoipCallTransaction("t", 10000L,
+        TestVoipCallTransaction t = new TestVoipCallTransaction("t", 10000L,
                 TestVoipCallTransaction.SUCCESS);
         CompletableFuture<String> exceptionFuture = new CompletableFuture<>();
         OutcomeReceiver<VoipCallTransactionResult, CallException> outcomeReceiver =
@@ -255,15 +264,16 @@
         mTransactionManager.addTransaction(t, outcomeReceiver);
         String message = exceptionFuture.get(7000L, TimeUnit.MILLISECONDS);
         assertTrue(message.contains("timeout"));
+        verifyTransactionsFinished(t);
     }
 
     @SmallTest
     @Test
     public void testTransactionException()
             throws ExecutionException, InterruptedException, TimeoutException {
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.EXCEPTION);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
                 TestVoipCallTransaction.SUCCESS);
         CompletableFuture<String> exceptionFuture = new CompletableFuture<>();
         OutcomeReceiver<VoipCallTransactionResult, CallException> outcomeExceptionReceiver =
@@ -290,17 +300,18 @@
         assertEquals(VoipCallTransactionResult.RESULT_SUCCEED,
                 resultFuture.get(5000L, TimeUnit.MILLISECONDS).getResult());
         assertEquals(expectedLog, mLog.toString());
+        verifyTransactionsFinished(t1, t2);
     }
 
     @SmallTest
     @Test
     public void testTransactionResultException()
             throws ExecutionException, InterruptedException, TimeoutException {
-        VoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
+        TestVoipCallTransaction t1 = new TestVoipCallTransaction("t1", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
+        TestVoipCallTransaction t2 = new TestVoipCallTransaction("t2", 1000L,
                 TestVoipCallTransaction.SUCCESS);
-        VoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
+        TestVoipCallTransaction t3 = new TestVoipCallTransaction("t3", 1000L,
                 TestVoipCallTransaction.SUCCESS);
         OutcomeReceiver<VoipCallTransactionResult, CallException> outcomeExceptionReceiver =
                 new OutcomeReceiver<>() {
@@ -335,5 +346,13 @@
         assertEquals(VoipCallTransactionResult.RESULT_SUCCEED,
                 resultFuture.get(5000L, TimeUnit.MILLISECONDS).getResult());
         assertEquals(expectedLog, mLog.toString());
+        verifyTransactionsFinished(t1, t2, t3);
+    }
+
+    public void verifyTransactionsFinished(TestVoipCallTransaction... transactions) {
+        for (TestVoipCallTransaction t : transactions) {
+            assertTrue("TestVoipCallTransaction[" + t.mName + "] never called finishTransaction",
+                    t.isFinished);
+        }
     }
 }