Add GSM conference calling to simulator.

This CL adds a new item to the simulator menu:
  - Add GSM conference

The GSM conference action creates a conference with 5 phone calls.
Users can individually separate or kick calls out of the conference. Hanging up the second last call finishes the conference.

Bug: 67785540
Test: SimulatorConferenceTest
PiperOrigin-RevId: 172377631
Change-Id: Ic30fa6c65cf782247f75bcdd1ecbd86b1c16f143
diff --git a/java/com/android/dialer/simulator/Simulator.java b/java/com/android/dialer/simulator/Simulator.java
index f753e5f..4812fa5 100644
--- a/java/com/android/dialer/simulator/Simulator.java
+++ b/java/com/android/dialer/simulator/Simulator.java
@@ -30,6 +30,16 @@
 
   ActionProvider getActionProvider(Context context);
 
+  /** The type of conference to emulate. */
+  // TODO(b/67785540): add VoLTE and CDMA conference call
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({
+    CONFERENCE_TYPE_GSM,
+  })
+  @interface ConferenceType {}
+
+  static final int CONFERENCE_TYPE_GSM = 1;
+
   /** Information about a connection event. */
   public static class Event {
     /** The type of connection event. */
@@ -44,6 +54,11 @@
       STATE_CHANGE,
       DTMF,
       SESSION_MODIFY_REQUEST,
+      CALL_AUDIO_STATE_CHANGED,
+      CONNECTION_ADDED,
+      MERGE,
+      SEPARATE,
+      SWAP,
     })
     public @interface Type {}
 
@@ -56,6 +71,11 @@
     public static final int STATE_CHANGE = 6;
     public static final int DTMF = 7;
     public static final int SESSION_MODIFY_REQUEST = 8;
+    public static final int CALL_AUDIO_STATE_CHANGED = 9;
+    public static final int CONNECTION_ADDED = 10;
+    public static final int MERGE = 11;
+    public static final int SEPARATE = 12;
+    public static final int SWAP = 13;
 
     @Type public final int type;
     /** Holds event specific information. For example, for DTMF this could be the keycode. */
diff --git a/java/com/android/dialer/simulator/impl/SimulatorConference.java b/java/com/android/dialer/simulator/impl/SimulatorConference.java
new file mode 100644
index 0000000..7468b56
--- /dev/null
+++ b/java/com/android/dialer/simulator/impl/SimulatorConference.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2017 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.dialer.simulator.impl;
+
+import android.support.annotation.NonNull;
+import android.telecom.CallAudioState;
+import android.telecom.Conference;
+import android.telecom.Connection;
+import android.telecom.PhoneAccountHandle;
+import com.android.dialer.common.Assert;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.simulator.Simulator;
+import com.android.dialer.simulator.Simulator.Event;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a conference call. When a user merges two phone calls we create an instance of this
+ * conference object and add it to the connection service. All operations such as hold and DTMF are
+ * then performed on this object.
+ */
+public final class SimulatorConference extends Conference implements SimulatorConnection.Listener {
+  static final int PROPERTY_GENERIC_CONFERENCE = 1 << 1;
+
+  private final List<Listener> listeners = new ArrayList<>();
+  private final List<Event> events = new ArrayList<>();
+  private final int conferenceType;
+
+  private SimulatorConference(
+      PhoneAccountHandle handle, @Simulator.ConferenceType int conferenceType) {
+    super(handle);
+    this.conferenceType = conferenceType;
+    setActive();
+  }
+
+  static SimulatorConference newGsmConference(PhoneAccountHandle handle) {
+    SimulatorConference simulatorConference =
+        new SimulatorConference(handle, Simulator.CONFERENCE_TYPE_GSM);
+    simulatorConference.setConnectionCapabilities(
+        Connection.CAPABILITY_MUTE
+            | Connection.CAPABILITY_SUPPORT_HOLD
+            | Connection.CAPABILITY_HOLD
+            | Connection.CAPABILITY_MANAGE_CONFERENCE);
+    return simulatorConference;
+  }
+
+  public void addListener(@NonNull Listener listener) {
+    listeners.add(Assert.isNotNull(listener));
+  }
+
+  public void removeListener(@NonNull Listener listener) {
+    listeners.remove(Assert.isNotNull(listener));
+  }
+
+  @NonNull
+  public List<Event> getEvents() {
+    return events;
+  }
+
+  @Override
+  public void onCallAudioStateChanged(CallAudioState state) {
+    LogUtil.enterBlock("SimulatorConference.onCallAudioStateChanged");
+    onEvent(new Event(Event.CALL_AUDIO_STATE_CHANGED));
+  }
+
+  @Override
+  public void onConnectionAdded(Connection connection) {
+    LogUtil.enterBlock("SimulatorConference.onConnectionAdded");
+    onEvent(
+        new Event(
+            Event.CONNECTION_ADDED, SimulatorSimCallManager.getConnectionTag(connection), null));
+    ((SimulatorConnection) connection).addListener(this);
+  }
+
+  @Override
+  public void onDisconnect() {
+    LogUtil.enterBlock("SimulatorConference.onDisconnect");
+    onEvent(new Event(Event.DISCONNECT));
+  }
+
+  @Override
+  public void onHold() {
+    LogUtil.enterBlock("SimulatorConference.onHold");
+    onEvent(new Event(Event.HOLD));
+  }
+
+  @Override
+  public void onMerge(Connection connection) {
+    LogUtil.i("SimulatorConference.onMerge", "connection: " + connection);
+    onEvent(new Event(Event.MERGE, SimulatorSimCallManager.getConnectionTag(connection), null));
+  }
+
+  @Override
+  public void onMerge() {
+    LogUtil.enterBlock("SimulatorConference.onMerge");
+    onEvent(new Event(Event.MERGE));
+  }
+
+  @Override
+  public void onPlayDtmfTone(char c) {
+    LogUtil.enterBlock("SimulatorConference.onPlayDtmfTone");
+    onEvent(new Event(Event.DTMF, Character.toString(c), null));
+  }
+
+  @Override
+  public void onSeparate(Connection connection) {
+    LogUtil.i("SimulatorConference.onSeparate", "connection: " + connection);
+    onEvent(new Event(Event.SEPARATE, SimulatorSimCallManager.getConnectionTag(connection), null));
+  }
+
+  @Override
+  public void onSwap() {
+    LogUtil.enterBlock("SimulatorConference.onSwap");
+    onEvent(new Event(Event.SWAP));
+  }
+
+  @Override
+  public void onUnhold() {
+    LogUtil.enterBlock("SimulatorConference.onUnhold");
+    onEvent(new Event(Event.UNHOLD));
+  }
+
+  @Override
+  public void onEvent(@NonNull SimulatorConnection connection, @NonNull Event event) {
+    if (conferenceType == Simulator.CONFERENCE_TYPE_GSM) {
+      onGsmEvent(connection, event);
+    }
+  }
+
+  private void onGsmEvent(@NonNull SimulatorConnection connection, @NonNull Event event) {
+    if (event.type == Event.STATE_CHANGE
+        && Connection.stateToString(Connection.STATE_DISCONNECTED).equals(event.data2)) {
+      removeConnection(connection);
+      connection.removeListener(this);
+      if (getConnections().size() <= 1) {
+        // When only one connection exists, it's not conference call anymore
+        setDisconnected(connection.getDisconnectCause());
+        destroy();
+      }
+    }
+  }
+
+  void onEvent(@NonNull Event event) {
+    events.add(Assert.isNotNull(event));
+    for (Listener listener : new ArrayList<>(listeners)) {
+      listener.onEvent(this, event);
+    }
+  }
+
+  /** Callback for when a new event arrives. */
+  public interface Listener {
+    void onEvent(@NonNull SimulatorConference conference, @NonNull Event event);
+  }
+}
diff --git a/java/com/android/dialer/simulator/impl/SimulatorConferenceCreator.java b/java/com/android/dialer/simulator/impl/SimulatorConferenceCreator.java
new file mode 100644
index 0000000..838b58d
--- /dev/null
+++ b/java/com/android/dialer/simulator/impl/SimulatorConferenceCreator.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2017 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.dialer.simulator.impl;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.telecom.Conferenceable;
+import android.telecom.Connection;
+import android.telecom.DisconnectCause;
+import com.android.dialer.common.Assert;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.common.concurrent.ThreadUtil;
+import com.android.dialer.simulator.Simulator;
+import com.android.dialer.simulator.Simulator.Event;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** Creates a conference with a given number of participants. */
+final class SimulatorConferenceCreator
+    implements SimulatorConnectionService.Listener,
+        SimulatorConnection.Listener,
+        SimulatorConference.Listener {
+  private static final String EXTRA_CALL_COUNT = "call_count";
+
+  @NonNull private final Context context;
+  @NonNull private final List<String> connectionTags = new ArrayList<>();
+  @Simulator.ConferenceType private final int conferenceType;
+
+  public SimulatorConferenceCreator(
+      @NonNull Context context, @Simulator.ConferenceType int conferenceType) {
+    this.context = Assert.isNotNull(context);
+    this.conferenceType = conferenceType;
+  }
+
+  void start(int callCount) {
+    SimulatorConnectionService.addListener(this);
+    addNextCall(callCount);
+  }
+
+  private void addNextCall(int callCount) {
+    LogUtil.i("SimulatorConferenceCreator.addNextIncomingCall", "callCount: " + callCount);
+    if (callCount <= 0) {
+      LogUtil.i("SimulatorConferenceCreator.addNextCall", "done adding calls");
+      return;
+    }
+
+    String callerId = String.format(Locale.US, "+1-650-234%04d", callCount);
+    Bundle extras = new Bundle();
+    extras.putInt(EXTRA_CALL_COUNT, callCount - 1);
+    connectionTags.add(
+        SimulatorSimCallManager.addNewIncomingCall(context, callerId, false /* isVideo */, extras));
+  }
+
+  @Override
+  public void onNewIncomingConnection(@NonNull SimulatorConnection connection) {
+    if (!isMyConnection(connection)) {
+      LogUtil.i("SimulatorConferenceCreator.onNewOutgoingConnection", "unknown connection");
+      return;
+    }
+
+    LogUtil.i("SimulatorConferenceCreator.onNewOutgoingConnection", "connection created");
+    connection.addListener(this);
+
+    // Telecom will force the connection to switch to DIALING when we return it. Wait until after
+    // we're returned it before changing call state.
+    ThreadUtil.postOnUiThread(() -> connection.setActive());
+
+    // Once the connection is active, go ahead and conference it and add the next call.
+    ThreadUtil.postDelayedOnUiThread(
+        () -> {
+          SimulatorConference conference = findCurrentConference();
+          if (conference == null) {
+            Assert.checkArgument(conferenceType == Simulator.CONFERENCE_TYPE_GSM);
+            conference =
+                SimulatorConference.newGsmConference(
+                    SimulatorSimCallManager.getSystemPhoneAccountHandle(context));
+            conference.addListener(this);
+            SimulatorConnectionService.getInstance().addConference(conference);
+          }
+          updateConferenceableConnections();
+          conference.addConnection(connection);
+          addNextCall(getCallCount(connection));
+        },
+        1000);
+  }
+
+  @Override
+  public void onNewOutgoingConnection(@NonNull SimulatorConnection connection) {}
+
+  /**
+   * This is called when the user clicks the merge button. We create the initial conference
+   * automatically but with this method we can let the user split and merge calls as desired.
+   */
+  @Override
+  public void onConference(
+      @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2) {
+    LogUtil.enterBlock("SimulatorConferenceCreator.onConference");
+    if (!isMyConnection(connection1) || !isMyConnection(connection2)) {
+      LogUtil.i("SimulatorConferenceCreator.onConference", "unknown connections, ignoring");
+      return;
+    }
+
+    if (connection1.getConference() != null) {
+      connection1.getConference().addConnection(connection2);
+    } else if (connection2.getConference() != null) {
+      connection2.getConference().addConnection(connection1);
+    } else {
+      Assert.checkArgument(conferenceType == Simulator.CONFERENCE_TYPE_GSM);
+      SimulatorConference conference =
+          SimulatorConference.newGsmConference(
+              SimulatorSimCallManager.getSystemPhoneAccountHandle(context));
+      conference.addConnection(connection1);
+      conference.addConnection(connection2);
+      conference.addListener(this);
+      SimulatorConnectionService.getInstance().addConference(conference);
+    }
+  }
+
+  private boolean isMyConnection(@NonNull Connection connection) {
+    for (String connectionTag : connectionTags) {
+      if (connection.getExtras().getBoolean(connectionTag)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private void updateConferenceableConnections() {
+    LogUtil.enterBlock("SimulatorConferenceCreator.updateConferenceableConnections");
+    for (String connectionTag : connectionTags) {
+      SimulatorConnection connection = SimulatorSimCallManager.findConnectionByTag(connectionTag);
+      List<Conferenceable> conferenceables = getMyConferenceables();
+      conferenceables.remove(connection);
+      conferenceables.remove(connection.getConference());
+      connection.setConferenceables(conferenceables);
+    }
+  }
+
+  private List<Conferenceable> getMyConferenceables() {
+    List<Conferenceable> conferenceables = new ArrayList<>();
+    for (String connectionTag : connectionTags) {
+      SimulatorConnection connection = SimulatorSimCallManager.findConnectionByTag(connectionTag);
+      conferenceables.add(connection);
+      if (connection.getConference() != null
+          && !conferenceables.contains(connection.getConference())) {
+        conferenceables.add(connection.getConference());
+      }
+    }
+    return conferenceables;
+  }
+
+  @Nullable
+  private SimulatorConference findCurrentConference() {
+    for (String connectionTag : connectionTags) {
+      SimulatorConnection connection = SimulatorSimCallManager.findConnectionByTag(connectionTag);
+      if (connection.getConference() != null) {
+        return (SimulatorConference) connection.getConference();
+      }
+    }
+    return null;
+  }
+
+  private static int getCallCount(@NonNull Connection connection) {
+    return connection.getExtras().getInt(EXTRA_CALL_COUNT);
+  }
+
+  @Override
+  public void onEvent(@NonNull SimulatorConnection connection, @NonNull Event event) {
+    switch (event.type) {
+      case Event.NONE:
+        throw Assert.createIllegalStateFailException();
+      case Event.HOLD:
+        connection.setOnHold();
+        break;
+      case Event.UNHOLD:
+        connection.setActive();
+        break;
+      case Event.DISCONNECT:
+        connection.setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
+        break;
+      default:
+        LogUtil.i(
+            "SimulatorConferenceCreator.onEvent", "unexpected conference event: " + event.type);
+        break;
+    }
+  }
+
+  @Override
+  public void onEvent(@NonNull SimulatorConference conference, @NonNull Event event) {
+    switch (event.type) {
+      case Event.MERGE:
+        int capabilities = conference.getConnectionCapabilities();
+        capabilities |= Connection.CAPABILITY_SWAP_CONFERENCE;
+        conference.setConnectionCapabilities(capabilities);
+        break;
+      case Event.SEPARATE:
+        SimulatorConnection connectionToRemove =
+            SimulatorSimCallManager.findConnectionByTag(event.data1);
+        conference.removeConnection(connectionToRemove);
+        break;
+      case Event.DISCONNECT:
+        for (Connection connection : new ArrayList<>(conference.getConnections())) {
+          connection.setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
+        }
+        break;
+      default:
+        LogUtil.i(
+            "SimulatorConferenceCreator.onEvent", "unexpected conference event: " + event.type);
+        break;
+    }
+  }
+}
diff --git a/java/com/android/dialer/simulator/impl/SimulatorConnection.java b/java/com/android/dialer/simulator/impl/SimulatorConnection.java
index 70c1095..e4a34b5 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorConnection.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorConnection.java
@@ -41,7 +41,9 @@
         CAPABILITY_MUTE
             | CAPABILITY_SUPPORT_HOLD
             | CAPABILITY_HOLD
-            | CAPABILITY_CAN_UPGRADE_TO_VIDEO);
+            | CAPABILITY_CAN_UPGRADE_TO_VIDEO
+            | CAPABILITY_DISCONNECT_FROM_CONFERENCE
+            | CAPABILITY_SEPARATE_FROM_CONFERENCE);
     setVideoProvider(new SimulatorVideoProvider(context, this));
   }
 
@@ -108,7 +110,7 @@
 
   void onEvent(@NonNull Event event) {
     events.add(Assert.isNotNull(event));
-    for (Listener listener : listeners) {
+    for (Listener listener : new ArrayList<>(listeners)) {
       listener.onEvent(this, event);
     }
   }
diff --git a/java/com/android/dialer/simulator/impl/SimulatorConnectionService.java b/java/com/android/dialer/simulator/impl/SimulatorConnectionService.java
index 25d4a72..465890c 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorConnectionService.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorConnectionService.java
@@ -109,6 +109,19 @@
     return connection;
   }
 
+  @Override
+  public void onConference(Connection connection1, Connection connection2) {
+    LogUtil.i(
+        "SimulatorConnectionService.onConference",
+        "connection1: "
+            + SimulatorSimCallManager.getConnectionTag(connection1)
+            + ", connection2: "
+            + SimulatorSimCallManager.getConnectionTag(connection2));
+    for (Listener listener : listeners) {
+      listener.onConference((SimulatorConnection) connection1, (SimulatorConnection) connection2);
+    }
+  }
+
   private static Uri getPhoneNumber(ConnectionRequest request) {
     String phoneNumber = request.getExtras().getString(TelephonyManager.EXTRA_INCOMING_NUMBER);
     return Uri.fromParts(PhoneAccount.SCHEME_TEL, phoneNumber, null);
@@ -116,8 +129,11 @@
 
   /** Callback used to notify listeners when a new connection has been added. */
   public interface Listener {
-    void onNewOutgoingConnection(SimulatorConnection connection);
+    void onNewOutgoingConnection(@NonNull SimulatorConnection connection);
 
-    void onNewIncomingConnection(SimulatorConnection connection);
+    void onNewIncomingConnection(@NonNull SimulatorConnection connection);
+
+    void onConference(
+        @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2);
   }
 }
diff --git a/java/com/android/dialer/simulator/impl/SimulatorMissedCallCreator.java b/java/com/android/dialer/simulator/impl/SimulatorMissedCallCreator.java
index f85f466..6d4a262 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorMissedCallCreator.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorMissedCallCreator.java
@@ -62,6 +62,10 @@
         DISCONNECT_DELAY_MILLIS);
   }
 
+  @Override
+  public void onConference(
+      @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2) {}
+
   private void addNextIncomingCall(int callCount) {
     if (callCount <= 0) {
       LogUtil.i("SimulatorMissedCallCreator.addNextIncomingCall", "done adding calls");
diff --git a/java/com/android/dialer/simulator/impl/SimulatorSimCallManager.java b/java/com/android/dialer/simulator/impl/SimulatorSimCallManager.java
index 33eac51..00899fd 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorSimCallManager.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorSimCallManager.java
@@ -21,6 +21,7 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.support.annotation.NonNull;
+import android.telecom.Connection;
 import android.telecom.ConnectionRequest;
 import android.telecom.PhoneAccount;
 import android.telecom.PhoneAccountHandle;
@@ -47,6 +48,7 @@
   private static final String SIM_CALL_MANAGER_ACCOUNT_ID = "SIMULATOR_ACCOUNT_ID";
   private static final String VIDEO_PROVIDER_ACCOUNT_ID = "SIMULATOR_VIDEO_ACCOUNT_ID";
   private static final String EXTRA_IS_SIMULATOR_CONNECTION = "is_simulator_connection";
+  private static final String EXTRA_CONNECTION_TAG = "connection_tag";
 
   static void register(@NonNull Context context) {
     LogUtil.enterBlock("SimulatorSimCallManager.register");
@@ -85,9 +87,7 @@
     register(context);
 
     extras = new Bundle(extras);
-    extras.putBoolean(EXTRA_IS_SIMULATOR_CONNECTION, true);
-    String connectionTag = createUniqueConnectionTag();
-    extras.putBoolean(connectionTag, true);
+    extras.putAll(createSimulatorConnectionExtras());
 
     Bundle outgoingCallExtras = new Bundle();
     outgoingCallExtras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, extras);
@@ -102,7 +102,7 @@
     } catch (SecurityException e) {
       throw Assert.createIllegalStateFailException("Unable to place call: " + e);
     }
-    return connectionTag;
+    return extras.getString(EXTRA_CONNECTION_TAG);
   }
 
   @NonNull
@@ -123,14 +123,12 @@
 
     extras = new Bundle(extras);
     extras.putString(TelephonyManager.EXTRA_INCOMING_NUMBER, callerId);
-    extras.putBoolean(EXTRA_IS_SIMULATOR_CONNECTION, true);
-    String connectionTag = createUniqueConnectionTag();
-    extras.putBoolean(connectionTag, true);
+    extras.putAll(createSimulatorConnectionExtras());
 
     TelecomManager telecomManager = context.getSystemService(TelecomManager.class);
     telecomManager.addNewIncomingCall(
         isVideo ? getVideoProviderHandle(context) : getSystemPhoneAccountHandle(context), extras);
-    return connectionTag;
+    return extras.getString(EXTRA_CONNECTION_TAG);
   }
 
   @NonNull
@@ -167,7 +165,7 @@
   }
 
   @NonNull
-  private static PhoneAccountHandle getSystemPhoneAccountHandle(@NonNull Context context) {
+  public static PhoneAccountHandle getSystemPhoneAccountHandle(@NonNull Context context) {
     TelecomManager telecomManager = context.getSystemService(TelecomManager.class);
     List<PhoneAccountHandle> handles;
     try {
@@ -190,10 +188,37 @@
   }
 
   @NonNull
+  public static String getConnectionTag(@NonNull Connection connection) {
+    String connectionTag = connection.getExtras().getString(EXTRA_CONNECTION_TAG);
+    return Assert.isNotNull(connectionTag);
+  }
+
+  @NonNull
+  public static SimulatorConnection findConnectionByTag(@NonNull String connectionTag) {
+    Assert.isNotNull(connectionTag);
+    for (Connection connection : SimulatorConnectionService.getInstance().getAllConnections()) {
+      if (connection.getExtras().getBoolean(connectionTag)) {
+        return (SimulatorConnection) connection;
+      }
+    }
+    throw Assert.createIllegalStateFailException();
+  }
+
+  @NonNull
   private static String createUniqueConnectionTag() {
     int callId = new Random().nextInt();
     return String.format("simulator_phone_call_%x", Math.abs(callId));
   }
 
+  @NonNull
+  static Bundle createSimulatorConnectionExtras() {
+    Bundle extras = new Bundle();
+    extras.putBoolean(EXTRA_IS_SIMULATOR_CONNECTION, true);
+    String connectionTag = createUniqueConnectionTag();
+    extras.putString(EXTRA_CONNECTION_TAG, connectionTag);
+    extras.putBoolean(connectionTag, true);
+    return extras;
+  }
+
   private SimulatorSimCallManager() {}
 }
diff --git a/java/com/android/dialer/simulator/impl/SimulatorSpamCallCreator.java b/java/com/android/dialer/simulator/impl/SimulatorSpamCallCreator.java
index 757658d..a843ec0 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorSpamCallCreator.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorSpamCallCreator.java
@@ -74,6 +74,10 @@
         DISCONNECT_DELAY_MILLIS);
   }
 
+  @Override
+  public void onConference(
+      @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2) {}
+
   private void addNextIncomingCall(int callCount) {
     if (callCount <= 0) {
       LogUtil.i("SimulatorSpamCallCreator.addNextIncomingCall", "done adding calls");
diff --git a/java/com/android/dialer/simulator/impl/SimulatorVideoCall.java b/java/com/android/dialer/simulator/impl/SimulatorVideoCall.java
index 3f00ab1..f7256a1 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorVideoCall.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorVideoCall.java
@@ -109,6 +109,10 @@
     }
   }
 
+  @Override
+  public void onConference(
+      @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2) {}
+
   private boolean isVideoAccountEnabled() {
     SimulatorSimCallManager.register(context);
     return context
@@ -150,15 +154,12 @@
       case Event.DISCONNECT:
         connection.setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
         break;
-      case Event.STATE_CHANGE:
-        break;
-      case Event.DTMF:
-        break;
       case Event.SESSION_MODIFY_REQUEST:
         ThreadUtil.postDelayedOnUiThread(() -> connection.handleSessionModifyRequest(event), 2000);
         break;
       default:
-        throw Assert.createIllegalStateFailException();
+        LogUtil.i("SimulatorVideoCall.onEvent", "unexpected event: " + event.type);
+        break;
     }
   }
 }
diff --git a/java/com/android/dialer/simulator/impl/SimulatorVoiceCall.java b/java/com/android/dialer/simulator/impl/SimulatorVoiceCall.java
index 8eefb48..f478d55 100644
--- a/java/com/android/dialer/simulator/impl/SimulatorVoiceCall.java
+++ b/java/com/android/dialer/simulator/impl/SimulatorVoiceCall.java
@@ -25,6 +25,7 @@
 import com.android.dialer.common.Assert;
 import com.android.dialer.common.LogUtil;
 import com.android.dialer.common.concurrent.ThreadUtil;
+import com.android.dialer.simulator.Simulator;
 import com.android.dialer.simulator.Simulator.Event;
 
 /** Entry point in the simulator to create voice calls. */
@@ -37,7 +38,10 @@
     return new SimulatorSubMenu(context)
         .addItem("Incoming call", () -> new SimulatorVoiceCall(context).addNewIncomingCall(false))
         .addItem("Outgoing call", () -> new SimulatorVoiceCall(context).addNewOutgoingCall())
-        .addItem("Spam call", () -> new SimulatorVoiceCall(context).addNewIncomingCall(true));
+        .addItem("Spam call", () -> new SimulatorVoiceCall(context).addNewIncomingCall(true))
+        .addItem(
+            "GSM conference",
+            () -> new SimulatorConferenceCreator(context, Simulator.CONFERENCE_TYPE_GSM).start(5));
   }
 
   private SimulatorVoiceCall(@NonNull Context context) {
@@ -62,21 +66,28 @@
 
   @Override
   public void onNewOutgoingConnection(@NonNull SimulatorConnection connection) {
-    if (connection.getExtras().getBoolean(connectionTag)) {
+    if (isMyConnection(connection)) {
       LogUtil.i("SimulatorVoiceCall.onNewOutgoingConnection", "connection created");
       handleNewConnection(connection);
-      connection.setActive();
+
+      // Telecom will force the connection to switch to Dialing when we return it. Wait until after
+      // we're returned it before changing call state.
+      ThreadUtil.postOnUiThread(connection::setActive);
     }
   }
 
   @Override
   public void onNewIncomingConnection(@NonNull SimulatorConnection connection) {
-    if (connection.getExtras().getBoolean(connectionTag)) {
+    if (isMyConnection(connection)) {
       LogUtil.i("SimulatorVoiceCall.onNewIncomingConnection", "connection created");
       handleNewConnection(connection);
     }
   }
 
+  @Override
+  public void onConference(
+      @NonNull SimulatorConnection connection1, @NonNull SimulatorConnection connection2) {}
+
   private void handleNewConnection(@NonNull SimulatorConnection connection) {
     connection.addListener(this);
     connection.setConnectionCapabilities(
@@ -85,6 +96,10 @@
             | Connection.CAPABILITY_SUPPORTS_VT_REMOTE_BIDIRECTIONAL);
   }
 
+  private boolean isMyConnection(@NonNull Connection connection) {
+    return connection.getExtras().getBoolean(connectionTag);
+  }
+
   @Override
   public void onEvent(@NonNull SimulatorConnection connection, @NonNull Event event) {
     switch (event.type) {
@@ -105,15 +120,12 @@
       case Event.DISCONNECT:
         connection.setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
         break;
-      case Event.STATE_CHANGE:
-        break;
-      case Event.DTMF:
-        break;
       case Event.SESSION_MODIFY_REQUEST:
         ThreadUtil.postDelayedOnUiThread(() -> connection.handleSessionModifyRequest(event), 2000);
         break;
       default:
-        throw Assert.createIllegalStateFailException();
+        LogUtil.i("SimulatorVoiceCall.onEvent", "unexpected event: " + event.type);
+        break;
     }
   }
 }