diff --git a/build.gradle b/build.gradle
index b0e6227544aecc3f059584390b76aab6247a1619..e3fb55a76b02645758345a297c1964a6a4f2206f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -95,7 +95,7 @@ android {
     compileSdk 34
 
     defaultConfig {
-        minSdkVersion 21
+        minSdkVersion 23
         targetSdkVersion 34
         versionCode 42094
         versionName "2.13.4"
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index c01009862191e65e6f10cf106d0a85a885632ecc..6233770df0d66e352b7cf7ae61d8d0f75eabf3d2 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -5,9 +5,6 @@
     <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
-    <uses-permission
-        android:name="android.permission.READ_PHONE_STATE"
-        android:maxSdkVersion="22" />
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -50,6 +47,8 @@
     <!-- this foreground service type permission is exclusively used for import and export backup -->
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
 
+    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
+
     <uses-feature
         android:name="android.hardware.camera"
         android:required="false" />
@@ -133,6 +132,14 @@
             </intent-filter>
         </service>
 
+        <service android:name=".services.CallIntegrationConnectionService"
+            android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.telecom.ConnectionService" />
+            </intent-filter>
+        </service>
+
         <receiver
             android:name=".services.EventReceiver"
             android:exported="false">
diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
index 3bed4eaba32604bd91ef082027a5f5e225fedb20..a472445d32bef97f697e32604a431051176a7e82 100644
--- a/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
+++ b/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
@@ -58,18 +58,18 @@ public class AppRTCAudioManager {
     private boolean hasWiredHeadset;
     // Default audio device; speaker phone for video calls or earpiece for audio
     // only calls.
-    private AudioDevice defaultAudioDevice;
+    private CallIntegration.AudioDevice defaultAudioDevice;
     // Contains the currently selected audio device.
     // This device is changed automatically using a certain scheme where e.g.
     // a wired headset "wins" over speaker phone. It is also possible for a
     // user to explicitly select a device (and overrid any predefined scheme).
     // See |userSelectedAudioDevice| for details.
-    private AudioDevice selectedAudioDevice;
+    private CallIntegration.AudioDevice selectedAudioDevice;
     // Contains the user-selected audio device which overrides the predefined
     // selection scheme.
     // TODO(henrika): always set to AudioDevice.NONE today. Add support for
     // explicit selection based on choice by userSelectedAudioDevice.
-    private AudioDevice userSelectedAudioDevice;
+    private CallIntegration.AudioDevice userSelectedAudioDevice;
     // Proximity sensor object. It measures the proximity of an object in cm
     // relative to the view screen of a device and can therefore be used to
     // assist device switching (close to ear <=> use headset earpiece if
@@ -78,26 +78,25 @@ public class AppRTCAudioManager {
     private AppRTCProximitySensor proximitySensor;
     // Contains a list of available audio devices. A Set collection is used to
     // avoid duplicate elements.
-    private Set<AudioDevice> audioDevices = new HashSet<>();
+    private Set<CallIntegration.AudioDevice> audioDevices = new HashSet<>();
     // Broadcast receiver for wired headset intent broadcasts.
     private final BroadcastReceiver wiredHeadsetReceiver;
     // Callback method for changes in audio focus.
     @Nullable
     private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
 
-    private AppRTCAudioManager(Context context, final SpeakerPhonePreference speakerPhonePreference) {
-        Log.d(Config.LOGTAG, "ctor");
+    public AppRTCAudioManager(final Context context) {
         ThreadUtils.checkIsOnMainThread();
         apprtcContext = context;
         audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
         bluetoothManager = AppRTCBluetoothManager.create(context, this);
         wiredHeadsetReceiver = new WiredHeadsetReceiver();
         amState = AudioManagerState.UNINITIALIZED;
-        this.speakerPhonePreference = speakerPhonePreference;
-        if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
-            defaultAudioDevice = AudioDevice.EARPIECE;
+        // CallIntegration / Connection uses Earpiece as default too
+        if (hasEarpiece()) {
+            defaultAudioDevice = CallIntegration.AudioDevice.EARPIECE;
         } else {
-            defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+            defaultAudioDevice = CallIntegration.AudioDevice.SPEAKER_PHONE;
         }
         // Create and initialize the proximity sensor.
         // Tablet devices (e.g. Nexus 7) does not support proximity sensors.
@@ -114,20 +113,13 @@ public class AppRTCAudioManager {
     public void switchSpeakerPhonePreference(final SpeakerPhonePreference speakerPhonePreference) {
         this.speakerPhonePreference = speakerPhonePreference;
         if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
-            defaultAudioDevice = AudioDevice.EARPIECE;
+            defaultAudioDevice = CallIntegration.AudioDevice.EARPIECE;
         } else {
-            defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+            defaultAudioDevice = CallIntegration.AudioDevice.SPEAKER_PHONE;
         }
         updateAudioDeviceState();
     }
 
-    /**
-     * Construction.
-     */
-    public static AppRTCAudioManager create(Context context, SpeakerPhonePreference speakerPhonePreference) {
-        return new AppRTCAudioManager(context, speakerPhonePreference);
-    }
-
     public static boolean isMicrophoneAvailable() {
         microphoneLatch = new CountDownLatch(1);
         AudioRecord audioRecord = null;
@@ -174,16 +166,16 @@ public class AppRTCAudioManager {
         }
         // The proximity sensor should only be activated when there are exactly two
         // available audio devices.
-        if (audioDevices.size() == 2 && audioDevices.contains(AppRTCAudioManager.AudioDevice.EARPIECE)
-                && audioDevices.contains(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE)) {
+        if (audioDevices.size() == 2 && audioDevices.contains(CallIntegration.AudioDevice.EARPIECE)
+                && audioDevices.contains(CallIntegration.AudioDevice.SPEAKER_PHONE)) {
             if (proximitySensor.sensorReportsNearState()) {
                 // Sensor reports that a "handset is being held up to a person's ear",
                 // or "something is covering the light sensor".
-                setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.EARPIECE);
+                setAudioDeviceInternal(CallIntegration.AudioDevice.EARPIECE);
             } else {
                 // Sensor reports that a "handset is removed from a person's ear", or
                 // "the light sensor is no longer covered".
-                setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
+                setAudioDeviceInternal(CallIntegration.AudioDevice.SPEAKER_PHONE);
             }
         }
     }
@@ -258,8 +250,8 @@ public class AppRTCAudioManager {
         // Always disable microphone mute during a WebRTC call.
         setMicrophoneMute(false);
         // Set initial device states.
-        userSelectedAudioDevice = AudioDevice.NONE;
-        selectedAudioDevice = AudioDevice.NONE;
+        userSelectedAudioDevice = CallIntegration.AudioDevice.NONE;
+        selectedAudioDevice = CallIntegration.AudioDevice.NONE;
         audioDevices.clear();
         // Initialize and start Bluetooth if a BT device is available or initiate
         // detection of new (enabled) BT devices.
@@ -315,7 +307,7 @@ public class AppRTCAudioManager {
     /**
      * Changes selection of the currently active audio device.
      */
-    private void setAudioDeviceInternal(AudioDevice device) {
+    private void setAudioDeviceInternal(CallIntegration.AudioDevice device) {
         Log.d(Config.LOGTAG, "setAudioDeviceInternal(device=" + device + ")");
         AppRTCUtils.assertIsTrue(audioDevices.contains(device));
         switch (device) {
@@ -338,7 +330,7 @@ public class AppRTCAudioManager {
      * Changes default audio device.
      * TODO(henrika): add usage of this method in the AppRTCMobile client.
      */
-    public void setDefaultAudioDevice(AudioDevice defaultDevice) {
+    public void setDefaultAudioDevice(CallIntegration.AudioDevice defaultDevice) {
         ThreadUtils.checkIsOnMainThread();
         switch (defaultDevice) {
             case SPEAKER_PHONE:
@@ -348,7 +340,7 @@ public class AppRTCAudioManager {
                 if (hasEarpiece()) {
                     defaultAudioDevice = defaultDevice;
                 } else {
-                    defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+                    defaultAudioDevice = CallIntegration.AudioDevice.SPEAKER_PHONE;
                 }
                 break;
             default:
@@ -362,7 +354,7 @@ public class AppRTCAudioManager {
     /**
      * Changes selection of the currently active audio device.
      */
-    public void selectAudioDevice(AudioDevice device) {
+    public void selectAudioDevice(CallIntegration.AudioDevice device) {
         ThreadUtils.checkIsOnMainThread();
         if (!audioDevices.contains(device)) {
             Log.e(Config.LOGTAG, "Can not select " + device + " from available " + audioDevices);
@@ -374,7 +366,7 @@ public class AppRTCAudioManager {
     /**
      * Returns current set of available/selectable audio devices.
      */
-    public Set<AudioDevice> getAudioDevices() {
+    public Set<CallIntegration.AudioDevice> getAudioDevices() {
         ThreadUtils.checkIsOnMainThread();
         return Collections.unmodifiableSet(new HashSet<>(audioDevices));
     }
@@ -382,7 +374,7 @@ public class AppRTCAudioManager {
     /**
      * Returns the currently selected audio device.
      */
-    public AudioDevice getSelectedAudioDevice() {
+    public CallIntegration.AudioDevice getSelectedAudioDevice() {
         ThreadUtils.checkIsOnMainThread();
         return selectedAudioDevice;
     }
@@ -479,21 +471,21 @@ public class AppRTCAudioManager {
             bluetoothManager.updateDevice();
         }
         // Update the set of available audio devices.
-        Set<AudioDevice> newAudioDevices = new HashSet<>();
+        Set<CallIntegration.AudioDevice> newAudioDevices = new HashSet<>();
         if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
                 || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
                 || bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
-            newAudioDevices.add(AudioDevice.BLUETOOTH);
+            newAudioDevices.add(CallIntegration.AudioDevice.BLUETOOTH);
         }
         if (hasWiredHeadset) {
             // If a wired headset is connected, then it is the only possible option.
-            newAudioDevices.add(AudioDevice.WIRED_HEADSET);
+            newAudioDevices.add(CallIntegration.AudioDevice.WIRED_HEADSET);
         } else {
             // No wired headset, hence the audio-device list can contain speaker
             // phone (on a tablet), or speaker phone and earpiece (on mobile phone).
-            newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
+            newAudioDevices.add(CallIntegration.AudioDevice.SPEAKER_PHONE);
             if (hasEarpiece()) {
-                newAudioDevices.add(AudioDevice.EARPIECE);
+                newAudioDevices.add(CallIntegration.AudioDevice.EARPIECE);
             }
         }
         // Store state which is set to true if the device list has changed.
@@ -502,33 +494,33 @@ public class AppRTCAudioManager {
         audioDevices = newAudioDevices;
         // Correct user selected audio devices if needed.
         if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
-                && userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
+                && userSelectedAudioDevice == CallIntegration.AudioDevice.BLUETOOTH) {
             // If BT is not available, it can't be the user selection.
-            userSelectedAudioDevice = AudioDevice.NONE;
+            userSelectedAudioDevice = CallIntegration.AudioDevice.NONE;
         }
-        if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
+        if (hasWiredHeadset && userSelectedAudioDevice == CallIntegration.AudioDevice.SPEAKER_PHONE) {
             // If user selected speaker phone, but then plugged wired headset then make
             // wired headset as user selected device.
-            userSelectedAudioDevice = AudioDevice.WIRED_HEADSET;
+            userSelectedAudioDevice = CallIntegration.AudioDevice.WIRED_HEADSET;
         }
-        if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
+        if (!hasWiredHeadset && userSelectedAudioDevice == CallIntegration.AudioDevice.WIRED_HEADSET) {
             // If user selected wired headset, but then unplugged wired headset then make
             // speaker phone as user selected device.
-            userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE;
+            userSelectedAudioDevice = CallIntegration.AudioDevice.SPEAKER_PHONE;
         }
         // Need to start Bluetooth if it is available and user either selected it explicitly or
         // user did not select any output device.
         boolean needBluetoothAudioStart =
                 bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
-                        && (userSelectedAudioDevice == AudioDevice.NONE
-                        || userSelectedAudioDevice == AudioDevice.BLUETOOTH);
+                        && (userSelectedAudioDevice == CallIntegration.AudioDevice.NONE
+                        || userSelectedAudioDevice == CallIntegration.AudioDevice.BLUETOOTH);
         // Need to stop Bluetooth audio if user selected different device and
         // Bluetooth SCO connection is established or in the process.
         boolean needBluetoothAudioStop =
                 (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
                         || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING)
-                        && (userSelectedAudioDevice != AudioDevice.NONE
-                        && userSelectedAudioDevice != AudioDevice.BLUETOOTH);
+                        && (userSelectedAudioDevice != CallIntegration.AudioDevice.NONE
+                        && userSelectedAudioDevice != CallIntegration.AudioDevice.BLUETOOTH);
         if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
                 || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
                 || bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
@@ -545,21 +537,21 @@ public class AppRTCAudioManager {
             // Attempt to start Bluetooth SCO audio (takes a few second to start).
             if (!bluetoothManager.startScoAudio()) {
                 // Remove BLUETOOTH from list of available devices since SCO failed.
-                audioDevices.remove(AudioDevice.BLUETOOTH);
+                audioDevices.remove(CallIntegration.AudioDevice.BLUETOOTH);
                 audioDeviceSetUpdated = true;
             }
         }
         // Update selected audio device.
-        final AudioDevice newAudioDevice;
+        final CallIntegration.AudioDevice newAudioDevice;
         if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
             // If a Bluetooth is connected, then it should be used as output audio
             // device. Note that it is not sufficient that a headset is available;
             // an active SCO channel must also be up and running.
-            newAudioDevice = AudioDevice.BLUETOOTH;
+            newAudioDevice = CallIntegration.AudioDevice.BLUETOOTH;
         } else if (hasWiredHeadset) {
             // If a wired headset is connected, but Bluetooth is not, then wired headset is used as
             // audio device.
-            newAudioDevice = AudioDevice.WIRED_HEADSET;
+            newAudioDevice = CallIntegration.AudioDevice.WIRED_HEADSET;
         } else {
             // No wired headset and no Bluetooth, hence the audio-device list can contain speaker
             // phone (on a tablet), or speaker phone and earpiece (on mobile phone).
@@ -582,12 +574,6 @@ public class AppRTCAudioManager {
         Log.d(Config.LOGTAG, "--- updateAudioDeviceState done");
     }
 
-    /**
-     * AudioDevice is the names of possible audio devices that we currently
-     * support.
-     */
-    public enum AudioDevice {SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE}
-
     /**
      * AudioManager state.
      */
@@ -615,7 +601,7 @@ public class AppRTCAudioManager {
     public interface AudioManagerEvents {
         // Callback fired once audio device is changed or list of available audio devices changed.
         void onAudioDeviceChanged(
-                AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
+                CallIntegration.AudioDevice selectedAudioDevice, Set<CallIntegration.AudioDevice> availableAudioDevices);
     }
 
     /* Receiver which handles changes in wired headset availability. */
diff --git a/src/main/java/eu/siacs/conversations/services/CallIntegration.java b/src/main/java/eu/siacs/conversations/services/CallIntegration.java
new file mode 100644
index 0000000000000000000000000000000000000000..bf12f5f4bd378a08a63245370b111a412b2e5eb0
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/services/CallIntegration.java
@@ -0,0 +1,408 @@
+package eu.siacs.conversations.services;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.telecom.CallAudioState;
+import android.telecom.CallEndpoint;
+import android.telecom.Connection;
+import android.telecom.DisconnectCause;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.ui.util.MainThreadExecutor;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.jingle.Media;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class CallIntegration extends Connection {
+
+    private final AppRTCAudioManager appRTCAudioManager;
+    private AudioDevice initialAudioDevice = null;
+    private final AtomicBoolean initialAudioDeviceConfigured = new AtomicBoolean(false);
+
+    private List<CallEndpoint> availableEndpoints = Collections.emptyList();
+
+    private Callback callback = null;
+
+    public CallIntegration(final Context context) {
+        if (selfManaged()) {
+            setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
+            this.appRTCAudioManager = null;
+        } else {
+            this.appRTCAudioManager = new AppRTCAudioManager(context);
+            this.appRTCAudioManager.start(this::onAudioDeviceChanged);
+            // TODO WebRTCWrapper would issue one call to  eventCallback.onAudioDeviceChanged
+        }
+        setRingbackRequested(true);
+    }
+
+    public void setCallback(final Callback callback) {
+        this.callback = callback;
+    }
+
+    @Override
+    public void onShowIncomingCallUi() {
+        Log.d(Config.LOGTAG, "onShowIncomingCallUi");
+        this.callback.onCallIntegrationShowIncomingCallUi();
+    }
+
+    @Override
+    public void onAnswer() {
+        Log.d(Config.LOGTAG, "onAnswer()");
+    }
+
+    @Override
+    public void onDisconnect() {
+        Log.d(Config.LOGTAG, "onDisconnect()");
+        this.callback.onCallIntegrationDisconnect();
+    }
+
+    @Override
+    public void onReject() {
+        Log.d(Config.LOGTAG, "onReject()");
+    }
+
+    @Override
+    public void onReject(final String replyMessage) {
+        Log.d(Config.LOGTAG, "onReject(" + replyMessage + ")");
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Override
+    public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
+        Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
+        this.availableEndpoints = availableEndpoints;
+        this.onAudioDeviceChanged(
+                getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
+                ImmutableSet.copyOf(
+                        Lists.transform(
+                                availableEndpoints,
+                                CallIntegration::getAudioDeviceUpsideDownCake)));
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Override
+    public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
+        Log.d(Config.LOGTAG, "onCallEndpointChanged()");
+        this.onAudioDeviceChanged(
+                getAudioDeviceUpsideDownCake(callEndpoint),
+                ImmutableSet.copyOf(
+                        Lists.transform(
+                                this.availableEndpoints,
+                                CallIntegration::getAudioDeviceUpsideDownCake)));
+    }
+
+    @Override
+    public void onCallAudioStateChanged(final CallAudioState state) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
+            return;
+        }
+        Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
+        this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
+    }
+
+    public Set<AudioDevice> getAudioDevices() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            return getAudioDevicesUpsideDownCake();
+        } else if (selfManaged()) {
+            return getAudioDevicesOreo();
+        } else {
+            return getAudioDevicesFallback();
+        }
+    }
+
+    public AudioDevice getSelectedAudioDevice() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            return getAudioDeviceUpsideDownCake();
+        } else if (selfManaged()) {
+            return getAudioDeviceOreo();
+        } else {
+            return getAudioDeviceFallback();
+        }
+    }
+
+    public void setAudioDevice(final AudioDevice audioDevice) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            setAudioDeviceUpsideDownCake(audioDevice);
+        } else if (selfManaged()) {
+            setAudioDeviceOreo(audioDevice);
+        } else {
+            setAudioDeviceFallback(audioDevice);
+        }
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
+        return ImmutableSet.copyOf(
+                Lists.transform(
+                        this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    private AudioDevice getAudioDeviceUpsideDownCake() {
+        return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
+        if (callEndpoint == null) {
+            return AudioDevice.NONE;
+        }
+        final var endpointType = callEndpoint.getEndpointType();
+        return switch (endpointType) {
+            case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
+            case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
+            case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
+            case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
+            case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
+            case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
+            default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
+        };
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
+        final var callEndpointOptional =
+                Iterables.tryFind(
+                        this.availableEndpoints,
+                        e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
+        if (callEndpointOptional.isPresent()) {
+            final var endpoint = callEndpointOptional.get();
+            requestCallEndpointChange(
+                    endpoint,
+                    MainThreadExecutor.getInstance(),
+                    result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
+        } else {
+            Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
+        }
+    }
+
+    private Set<AudioDevice> getAudioDevicesOreo() {
+        final var audioState = getCallAudioState();
+        if (audioState == null) {
+            Log.d(
+                    Config.LOGTAG,
+                    "no CallAudioState available. returning empty set for audio devices");
+            return Collections.emptySet();
+        }
+        return getAudioDevicesOreo(audioState);
+    }
+
+    private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
+        final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
+                new ImmutableSet.Builder<>();
+        final var supportedRouteMask = callAudioState.getSupportedRouteMask();
+        if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
+                == CallAudioState.ROUTE_BLUETOOTH) {
+            supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
+        }
+        if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
+            supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
+        }
+        if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
+            supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
+        }
+        if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
+                == CallAudioState.ROUTE_WIRED_HEADSET) {
+            supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
+        }
+        return supportedAudioDevicesBuilder.build();
+    }
+
+    private AudioDevice getAudioDeviceOreo() {
+        final var audioState = getCallAudioState();
+        if (audioState == null) {
+            Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
+            return AudioDevice.NONE;
+        }
+        return getAudioDeviceOreo(audioState);
+    }
+
+    private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
+        // technically we get a mask here; maybe we should query the mask instead
+        return switch (audioState.getRoute()) {
+            case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
+            case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
+            case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
+            case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
+            default -> AudioDevice.NONE;
+        };
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    private void setAudioDeviceOreo(final AudioDevice audioDevice) {
+        switch (audioDevice) {
+            case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
+            case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
+            case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
+            case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
+        }
+    }
+
+    private Set<AudioDevice> getAudioDevicesFallback() {
+        return requireAppRtcAudioManager().getAudioDevices();
+    }
+
+    private AudioDevice getAudioDeviceFallback() {
+        return requireAppRtcAudioManager().getSelectedAudioDevice();
+    }
+
+    private void setAudioDeviceFallback(final AudioDevice audioDevice) {
+        requireAppRtcAudioManager().setDefaultAudioDevice(audioDevice);
+    }
+
+    @NonNull
+    private AppRTCAudioManager requireAppRtcAudioManager() {
+        if (this.appRTCAudioManager == null) {
+            throw new IllegalStateException(
+                    "You are trying to access the fallback audio manager on a modern device");
+        }
+        return this.appRTCAudioManager;
+    }
+
+    @Override
+    public void onStateChanged(final int state) {
+        Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
+        if (state == STATE_DISCONNECTED) {
+            final var audioManager = this.appRTCAudioManager;
+            if (audioManager != null) {
+                audioManager.stop();
+            }
+        }
+    }
+
+    public void success() {
+        Log.d(Config.LOGTAG, "CallIntegration.success()");
+        this.destroyWith(new DisconnectCause(DisconnectCause.LOCAL, null));
+    }
+
+    public void accepted() {
+        Log.d(Config.LOGTAG, "CallIntegration.accepted()");
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+            this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
+        } else {
+            this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
+        }
+    }
+
+    public void error() {
+        Log.d(Config.LOGTAG, "CallIntegration.error()");
+        this.destroyWith(new DisconnectCause(DisconnectCause.ERROR, null));
+    }
+
+    public void retracted() {
+        Log.d(Config.LOGTAG, "CallIntegration.retracted()");
+        // an alternative cause would be LOCAL
+        this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
+    }
+
+    public void rejected() {
+        Log.d(Config.LOGTAG, "CallIntegration.rejected()");
+        this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
+    }
+
+    public void busy() {
+        Log.d(Config.LOGTAG, "CallIntegration.busy()");
+        this.destroyWith(new DisconnectCause(DisconnectCause.BUSY, null));
+    }
+
+    private void destroyWith(final DisconnectCause disconnectCause) {
+        if (this.getState() == STATE_DISCONNECTED) {
+            Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
+            return;
+        }
+        this.setDisconnected(disconnectCause);
+        this.destroy();
+    }
+
+    public static Uri address(final Jid contact) {
+        return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
+    }
+
+    public void verifyDisconnected() {
+        if (this.getState() == STATE_DISCONNECTED) {
+            return;
+        }
+        throw new AssertionError("CallIntegration has not been disconnected");
+    }
+
+    private void onAudioDeviceChanged(
+            final CallIntegration.AudioDevice selectedAudioDevice,
+            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
+        if (this.initialAudioDevice != null
+                && this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
+            if (availableAudioDevices.contains(this.initialAudioDevice)) {
+                setAudioDevice(this.initialAudioDevice);
+                Log.d(Config.LOGTAG, "configured initial audio device");
+            } else {
+                Log.d(
+                        Config.LOGTAG,
+                        "initial audio device not available. available devices: "
+                                + availableAudioDevices);
+            }
+        }
+        final var callback = this.callback;
+        if (callback == null) {
+            return;
+        }
+        callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
+    }
+
+    public static boolean selfManaged() {
+        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
+    }
+
+    public void setInitialAudioDevice(final AudioDevice audioDevice) {
+        Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
+        this.initialAudioDevice = audioDevice;
+        if (CallIntegration.selfManaged()) {
+            // once the 'CallIntegration' gets added to the system we receive calls to update audio
+            // state
+            return;
+        }
+        final var audioManager = requireAppRtcAudioManager();
+        this.onAudioDeviceChanged(
+                audioManager.getSelectedAudioDevice(), audioManager.getAudioDevices());
+    }
+
+    /** AudioDevice is the names of possible audio devices that we currently support. */
+    public enum AudioDevice {
+        NONE,
+        SPEAKER_PHONE,
+        WIRED_HEADSET,
+        EARPIECE,
+        BLUETOOTH,
+        STREAMING
+    }
+
+    public static AudioDevice initialAudioDevice(final Set<Media> media) {
+        if (Media.audioOnly(media)) {
+            return AudioDevice.EARPIECE;
+        } else {
+            return AudioDevice.SPEAKER_PHONE;
+        }
+    }
+
+    public interface Callback {
+        void onCallIntegrationShowIncomingCallUi();
+
+        void onCallIntegrationDisconnect();
+
+        void onAudioDeviceChanged(
+                CallIntegration.AudioDevice selectedAudioDevice,
+                Set<CallIntegration.AudioDevice> availableAudioDevices);
+    }
+}
diff --git a/src/main/java/eu/siacs/conversations/services/CallIntegrationConnectionService.java b/src/main/java/eu/siacs/conversations/services/CallIntegrationConnectionService.java
new file mode 100644
index 0000000000000000000000000000000000000000..cfd6ae603c91bc5e1bcdc8e724972b3571faa201
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/services/CallIntegrationConnectionService.java
@@ -0,0 +1,255 @@
+package eu.siacs.conversations.services;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.telecom.Connection;
+import android.telecom.ConnectionRequest;
+import android.telecom.ConnectionService;
+import android.telecom.DisconnectCause;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.telecom.VideoProfile;
+import android.util.Log;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.ui.RtpSessionActivity;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
+import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
+import eu.siacs.conversations.xmpp.jingle.Media;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public class CallIntegrationConnectionService extends ConnectionService {
+
+    private ListenableFuture<ServiceConnectionService> serviceFuture;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        this.serviceFuture = ServiceConnectionService.bindService(this);
+    }
+
+    @Override
+    public void onDestroy() {
+        Log.d(Config.LOGTAG, "destroying CallIntegrationConnectionService");
+        super.onDestroy();
+        final ServiceConnection serviceConnection;
+        try {
+            serviceConnection = serviceFuture.get().serviceConnection;
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "could not fetch service connection", e);
+            return;
+        }
+        this.unbindService(serviceConnection);
+    }
+
+    @Override
+    public Connection onCreateOutgoingConnection(
+            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
+        Log.d(Config.LOGTAG, "onCreateOutgoingConnection(" + request.getAddress() + ")");
+        final var uri = request.getAddress();
+        final var jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
+        final var extras = request.getExtras();
+        final int videoState = extras.getInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE);
+        final Set<Media> media =
+                videoState == VideoProfile.STATE_AUDIO_ONLY
+                        ? ImmutableSet.of(Media.AUDIO)
+                        : ImmutableSet.of(Media.AUDIO, Media.VIDEO);
+        Log.d(Config.LOGTAG, "jid=" + jid);
+        Log.d(Config.LOGTAG, "phoneAccountHandle:" + phoneAccountHandle.getId());
+        Log.d(Config.LOGTAG, "media " + media);
+        final var service = ServiceConnectionService.get(this.serviceFuture);
+        if (service == null) {
+            return Connection.createFailedConnection(
+                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
+        }
+        final Account account = service.findAccountByUuid(phoneAccountHandle.getId());
+        final Intent intent = new Intent(this, RtpSessionActivity.class);
+        intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toEscapedString());
+        intent.putExtra(RtpSessionActivity.EXTRA_WITH, jid.toEscapedString());
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
+        final CallIntegration callIntegration;
+        if (jid.isBareJid()) {
+            final var proposal =
+                    service.getJingleConnectionManager()
+                            .proposeJingleRtpSession(account, jid, media);
+
+            if (Media.audioOnly(media)) {
+                intent.setAction(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
+            } else {
+                intent.setAction(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
+            }
+            callIntegration = proposal.getCallIntegration();
+        } else {
+            final JingleRtpConnection jingleRtpConnection =
+                    service.getJingleConnectionManager().initializeRtpSession(account, jid, media);
+            final String sessionId = jingleRtpConnection.getId().sessionId;
+            intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
+            callIntegration = jingleRtpConnection.getCallIntegration();
+        }
+        Log.d(Config.LOGTAG, "start activity!");
+        startActivity(intent);
+        return callIntegration;
+    }
+
+    public Connection onCreateIncomingConnection(
+            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
+        final var service = ServiceConnectionService.get(this.serviceFuture);
+        final Bundle extras = request.getExtras();
+        final Bundle extraExtras = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS);
+        final String incomingCallAddress =
+                extras.getString(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS);
+        final String sid = extraExtras == null ? null : extraExtras.getString("sid");
+        Log.d(Config.LOGTAG, "sid " + sid);
+        final Uri uri = incomingCallAddress == null ? null : Uri.parse(incomingCallAddress);
+        Log.d(Config.LOGTAG, "uri=" + uri);
+        if (uri == null || sid == null) {
+            return Connection.createFailedConnection(
+                    new DisconnectCause(
+                            DisconnectCause.ERROR,
+                            "connection request is missing required information"));
+        }
+        if (service == null) {
+            return Connection.createFailedConnection(
+                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
+        }
+        final var jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
+        final Account account = service.findAccountByUuid(phoneAccountHandle.getId());
+        final var weakReference =
+                service.getJingleConnectionManager().findJingleRtpConnection(account, jid, sid);
+        if (weakReference == null) {
+            Log.d(Config.LOGTAG, "no connection found for " + jid + " and sid=" + sid);
+            return Connection.createFailedConnection(
+                    new DisconnectCause(DisconnectCause.ERROR, "no incoming connection found"));
+        }
+        final var jingleRtpConnection = weakReference.get();
+        if (jingleRtpConnection == null) {
+            Log.d(Config.LOGTAG, "connection has been terminated");
+            return Connection.createFailedConnection(
+                    new DisconnectCause(DisconnectCause.ERROR, "connection has been terminated"));
+        }
+        Log.d(Config.LOGTAG, "registering call integration for incoming call");
+        return jingleRtpConnection.getCallIntegration();
+    }
+
+    public static void registerPhoneAccount(final Context context, final Account account) {
+        final var builder =
+                PhoneAccount.builder(getHandle(context, account), account.getJid().asBareJid());
+        builder.setSupportedUriSchemes(Collections.singletonList("xmpp"));
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            builder.setCapabilities(
+                    PhoneAccount.CAPABILITY_SELF_MANAGED
+                            | PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING);
+        }
+        final var phoneAccount = builder.build();
+
+        context.getSystemService(TelecomManager.class).registerPhoneAccount(phoneAccount);
+    }
+
+    public static void registerPhoneAccounts(
+            final Context context, final Collection<Account> accounts) {
+        for (final Account account : accounts) {
+            registerPhoneAccount(context, account);
+        }
+    }
+
+    public static PhoneAccountHandle getHandle(final Context context, final Account account) {
+        final var competentName =
+                new ComponentName(context, CallIntegrationConnectionService.class);
+        return new PhoneAccountHandle(competentName, account.getUuid());
+    }
+
+    public static void placeCall(
+            final Context context, final Account account, final Jid with, final Set<Media> media) {
+        Log.d(Config.LOGTAG, "place call media=" + media);
+        final var extras = new Bundle();
+        extras.putParcelable(
+                TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, getHandle(context, account));
+        extras.putInt(
+                TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
+                Media.audioOnly(media)
+                        ? VideoProfile.STATE_AUDIO_ONLY
+                        : VideoProfile.STATE_BIDIRECTIONAL);
+        context.getSystemService(TelecomManager.class)
+                .placeCall(CallIntegration.address(with), extras);
+    }
+
+    public static void addNewIncomingCall(
+            final Context context, final AbstractJingleConnection.Id id) {
+        final var phoneAccountHandle =
+                CallIntegrationConnectionService.getHandle(context, id.account);
+        final var bundle = new Bundle();
+        bundle.putString(
+                TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
+                CallIntegration.address(id.with).toString());
+        final var extras = new Bundle();
+        extras.putString("sid", id.sessionId);
+        bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
+        context.getSystemService(TelecomManager.class)
+                .addNewIncomingCall(phoneAccountHandle, bundle);
+    }
+
+    public static class ServiceConnectionService {
+        private final ServiceConnection serviceConnection;
+        private final XmppConnectionService service;
+
+        public ServiceConnectionService(
+                final ServiceConnection serviceConnection, final XmppConnectionService service) {
+            this.serviceConnection = serviceConnection;
+            this.service = service;
+        }
+
+        public static XmppConnectionService get(
+                final ListenableFuture<ServiceConnectionService> future) {
+            try {
+                return future.get(2, TimeUnit.SECONDS).service;
+            } catch (final ExecutionException | InterruptedException | TimeoutException e) {
+                return null;
+            }
+        }
+
+        public static ListenableFuture<ServiceConnectionService> bindService(
+                final Context context) {
+            final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
+                    SettableFuture.create();
+            final var intent = new Intent(context, XmppConnectionService.class);
+            intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
+            final var serviceConnection =
+                    new ServiceConnection() {
+
+                        @Override
+                        public void onServiceConnected(
+                                final ComponentName name, final IBinder iBinder) {
+                            final XmppConnectionService.XmppConnectionBinder binder =
+                                    (XmppConnectionService.XmppConnectionBinder) iBinder;
+                            serviceConnectionFuture.set(
+                                    new ServiceConnectionService(this, binder.getService()));
+                        }
+
+                        @Override
+                        public void onServiceDisconnected(final ComponentName name) {}
+                    };
+            context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+            return serviceConnectionFuture;
+        }
+    }
+}
diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index 58423715684bd1b42aa7a1dc63cf32b93d718b0d..5f48fca58e9516a182b831b45166e4b70c05b2ee 100644
--- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
+++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
@@ -198,6 +198,7 @@ public class XmppConnectionService extends Service {
     public static final String ACTION_DISMISS_CALL = "dismiss_call";
     public static final String ACTION_END_CALL = "end_call";
     public static final String ACTION_PROVISION_ACCOUNT = "provision_account";
+    public static final String ACTION_CALL_INTEGRATION_SERVICE_STARTED = "call_integration_service_started";
     private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE";
     public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS = "eu.siacs.conversations.UNIFIED_PUSH_RENEW";
     public static final String ACTION_QUICK_LOG = "eu.siacs.conversations.QUICK_LOG";
@@ -303,16 +304,6 @@ public class XmppConnectionService extends Service {
             return false;
         }
     };
-    private final AtomicBoolean isPhoneInCall = new AtomicBoolean(false);
-    private final PhoneStateListener phoneStateListener = new PhoneStateListener() {
-        @Override
-        public void onCallStateChanged(final int state, final String phoneNumber) {
-            isPhoneInCall.set(state != TelephonyManager.CALL_STATE_IDLE);
-            if (state == TelephonyManager.CALL_STATE_OFFHOOK) {
-                mJingleConnectionManager.notifyPhoneCallStarted();
-            }
-        }
-    };
 
     private boolean destroyed = false;
 
@@ -1288,6 +1279,8 @@ public class XmppConnectionService extends Service {
         toggleSetProfilePictureActivity(hasEnabledAccounts);
         reconfigurePushDistributor();
 
+        CallIntegrationConnectionService.registerPhoneAccounts(this, this.accounts);
+
         restoreFromDatabase();
 
         if (QuickConversationsService.isContactListIntegration(this)
@@ -1351,23 +1344,10 @@ public class XmppConnectionService extends Service {
                 ContextCompat.RECEIVER_EXPORTED);
         mForceDuringOnCreate.set(false);
         toggleForegroundService();
-        setupPhoneStateListener();
         internalPingExecutor.scheduleAtFixedRate(this::manageAccountConnectionStatesInternal,10,10,TimeUnit.SECONDS);
     }
 
 
-    private void setupPhoneStateListener() {
-        final TelephonyManager telephonyManager = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
-        if (telephonyManager == null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            return;
-        }
-        telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE);
-    }
-
-    public boolean isPhoneInCall() {
-        return isPhoneInCall.get();
-    }
-
     private void checkForDeletedFiles() {
         if (destroyed) {
             Log.d(Config.LOGTAG, "Do not check for deleted files because service has been destroyed");
@@ -4413,7 +4393,7 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void notifyJingleRtpConnectionUpdate(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+    public void notifyJingleRtpConnectionUpdate(CallIntegration.AudioDevice selectedAudioDevice, Set<CallIntegration.AudioDevice> availableAudioDevices) {
         for (OnJingleRtpConnectionUpdate listener : threadSafeList(this.onJingleRtpConnectionUpdate)) {
             listener.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
         }
@@ -5110,7 +5090,7 @@ public class XmppConnectionService extends Service {
     public interface OnJingleRtpConnectionUpdate {
         void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state);
 
-        void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
+        void onAudioDeviceChanged(CallIntegration.AudioDevice selectedAudioDevice, Set<CallIntegration.AudioDevice> availableAudioDevices);
     }
 
     public interface OnAccountUpdate {
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
index d19bc4a55cba0e01326013afb0ba68b9ada633a4..ccb08f95f006ab53d2db1b251883b2b0e313994b 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
@@ -86,6 +86,7 @@ import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.entities.TransferablePlaceholder;
 import eu.siacs.conversations.http.HttpDownloadConnection;
 import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.services.CallIntegrationConnectionService;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.XmppConnectionService;
@@ -1652,13 +1653,14 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void triggerRtpSession(final Account account, final Jid with, final String action) {
-        final Intent intent = new Intent(activity, RtpSessionActivity.class);
+        CallIntegrationConnectionService.placeCall(requireActivity(),account,with,RtpSessionActivity.actionToMedia(action));
+        /*final Intent intent = new Intent(activity, RtpSessionActivity.class);
         intent.setAction(action);
         intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toEscapedString());
         intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toEscapedString());
         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
         intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
-        startActivity(intent);
+        startActivity(intent);*/
     }
 
     private void handleAttachmentSelection(MenuItem item) {
diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
index 5b5c82baefb554d883afcc30092b02f87085d756..8f546918ab0a3fdbf58eb13e7dea93493e978284 100644
--- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
@@ -49,6 +49,8 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.services.AppRTCAudioManager;
+import eu.siacs.conversations.services.CallIntegration;
+import eu.siacs.conversations.services.CallIntegrationConnectionService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.ui.util.MainThreadExecutor;
@@ -133,7 +135,7 @@ public class RtpSessionActivity extends XmppActivity
                 }
             };
 
-    private static Set<Media> actionToMedia(final String action) {
+    public static Set<Media> actionToMedia(final String action) {
         if (ACTION_MAKE_VIDEO_CALL.equals(action)) {
             return ImmutableSet.of(Media.AUDIO, Media.VIDEO);
         } else {
@@ -416,11 +418,11 @@ public class RtpSessionActivity extends XmppActivity
         if (Media.audioOnly(media)) {
             final JingleRtpConnection rtpConnection =
                     rtpConnectionReference != null ? rtpConnectionReference.get() : null;
-            final AppRTCAudioManager audioManager =
-                    rtpConnection == null ? null : rtpConnection.getAudioManager();
-            if (audioManager == null
-                    || audioManager.getSelectedAudioDevice()
-                            == AppRTCAudioManager.AudioDevice.EARPIECE) {
+            final CallIntegration callIntegration =
+                    rtpConnection == null ? null : rtpConnection.getCallIntegration();
+            if (callIntegration == null
+                    || callIntegration.getSelectedAudioDevice()
+                            == CallIntegration.AudioDevice.EARPIECE) {
                 acquireProximityWakeLock();
             }
         }
@@ -466,8 +468,8 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     private void putProximityWakeLockInProperState(
-            final AppRTCAudioManager.AudioDevice audioDevice) {
-        if (audioDevice == AppRTCAudioManager.AudioDevice.EARPIECE) {
+            final CallIntegration.AudioDevice audioDevice) {
+        if (audioDevice == CallIntegration.AudioDevice.EARPIECE) {
             acquireProximityWakeLock();
         } else {
             releaseProximityWakeLock();
@@ -581,12 +583,7 @@ public class RtpSessionActivity extends XmppActivity
                     .getJingleConnectionManager()
                     .proposeJingleRtpSession(account, with, media);
         } else {
-            final String sessionId =
-                    xmppConnectionService
-                            .getJingleConnectionManager()
-                            .initializeRtpSession(account, with, media);
-            initializeActivityWithRunningRtpSession(account, with, sessionId);
-            resetIntent(account, with, sessionId);
+            throw new IllegalStateException("We should not be initializing direct calls from the RtpSessionActivity. Go through CallIntegrationConnectionService.placeCall instead!");
         }
         putScreenInCallMode(media);
     }
@@ -1032,10 +1029,10 @@ public class RtpSessionActivity extends XmppActivity
                 updateInCallButtonConfigurationVideo(
                         rtpConnection.isVideoEnabled(), rtpConnection.isCameraSwitchable());
             } else {
-                final AppRTCAudioManager audioManager = requireRtpConnection().getAudioManager();
+                final CallIntegration callIntegration = requireRtpConnection().getCallIntegration();
                 updateInCallButtonConfigurationSpeaker(
-                        audioManager.getSelectedAudioDevice(),
-                        audioManager.getAudioDevices().size());
+                        callIntegration.getSelectedAudioDevice(),
+                        callIntegration.getAudioDevices().size());
                 this.binding.inCallActionFarRight.setVisibility(View.GONE);
             }
             if (media.contains(Media.AUDIO)) {
@@ -1053,7 +1050,7 @@ public class RtpSessionActivity extends XmppActivity
 
     @SuppressLint("RestrictedApi")
     private void updateInCallButtonConfigurationSpeaker(
-            final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) {
+            final CallIntegration.AudioDevice selectedAudioDevice, final int numberOfChoices) {
         switch (selectedAudioDevice) {
             case EARPIECE -> {
                 this.binding.inCallActionRight.setImageResource(
@@ -1294,19 +1291,19 @@ public class RtpSessionActivity extends XmppActivity
 
     private void switchToEarpiece(View view) {
         requireRtpConnection()
-                .getAudioManager()
-                .setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.EARPIECE);
+                .getCallIntegration()
+                .setAudioDevice(CallIntegration.AudioDevice.EARPIECE);
         acquireProximityWakeLock();
     }
 
     private void switchToSpeaker(View view) {
         requireRtpConnection()
-                .getAudioManager()
-                .setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
+                .getCallIntegration()
+                .setAudioDevice(CallIntegration.AudioDevice.SPEAKER_PHONE);
         releaseProximityWakeLock();
     }
 
-    private void retry(View view) {
+    private void retry(final View view) {
         final Intent intent = getIntent();
         final Account account = extractAccount(intent);
         final Jid with = Jid.ofEscaped(intent.getStringExtra(EXTRA_WITH));
@@ -1315,7 +1312,7 @@ public class RtpSessionActivity extends XmppActivity
         final Set<Media> media = actionToMedia(lastAction == null ? action : lastAction);
         this.rtpConnectionReference = null;
         Log.d(Config.LOGTAG, "attempting retry with " + with.toEscapedString());
-        proposeJingleRtpSession(account, with, media);
+        CallIntegrationConnectionService.placeCall(this,account,with,media);
     }
 
     private void exit(final View view) {
@@ -1411,8 +1408,8 @@ public class RtpSessionActivity extends XmppActivity
 
     @Override
     public void onAudioDeviceChanged(
-            final AppRTCAudioManager.AudioDevice selectedAudioDevice,
-            final Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+            final CallIntegration.AudioDevice selectedAudioDevice,
+            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
         Log.d(
                 Config.LOGTAG,
                 "onAudioDeviceChanged in activity: selected:"
@@ -1428,11 +1425,11 @@ public class RtpSessionActivity extends XmppActivity
                         "onAudioDeviceChanged() nothing to do because end card has been reached");
             } else {
                 if (Media.audioOnly(media) && endUserState == RtpEndUserState.CONNECTED) {
-                    final AppRTCAudioManager audioManager =
-                            requireRtpConnection().getAudioManager();
+                    final CallIntegration callIntegration =
+                            requireRtpConnection().getCallIntegration();
                     updateInCallButtonConfigurationSpeaker(
-                            audioManager.getSelectedAudioDevice(),
-                            audioManager.getAudioDevices().size());
+                            callIntegration.getSelectedAudioDevice(),
+                            callIntegration.getAudioDevices().size());
                 }
                 Log.d(
                         Config.LOGTAG,
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
index 23d4f175b951d9a2905d68685536c72a6cb759c6..d4c9189ebe9766e029b397d8e7550b9f80d043b6 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
@@ -1,5 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import android.os.Bundle;
+import android.telecom.TelecomManager;
 import android.util.Base64;
 import android.util.Log;
 
@@ -21,6 +23,8 @@ import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.services.AbstractConnectionManager;
+import eu.siacs.conversations.services.CallIntegration;
+import eu.siacs.conversations.services.CallIntegrationConnectionService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
@@ -135,6 +139,9 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                 return;
             }
             connections.put(id, connection);
+
+            CallIntegrationConnectionService.addNewIncomingCall(getXmppConnectionService(), id);
+
             mXmppConnectionService.updateConversationUi();
             connection.deliverPacket(packet);
         } else {
@@ -148,12 +155,9 @@ public class JingleConnectionManager extends AbstractConnectionManager {
     }
 
     public boolean isBusy() {
-        if (mXmppConnectionService.isPhoneInCall()) {
-            return true;
-        }
         for (AbstractJingleConnection connection : this.connections.values()) {
             if (connection instanceof JingleRtpConnection) {
-                if (((JingleRtpConnection) connection).isTerminated()) {
+                if (connection.isTerminated()) {
                     continue;
                 }
                 return true;
@@ -181,17 +185,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return false;
     }
 
-    public void notifyPhoneCallStarted() {
-        for (AbstractJingleConnection connection : connections.values()) {
-            if (connection instanceof JingleRtpConnection rtpConnection) {
-                if (rtpConnection.isTerminated()) {
-                    continue;
-                }
-                rtpConnection.notifyPhoneCall();
-            }
-        }
-    }
-
     private Optional<RtpSessionProposal> findMatchingSessionProposal(
             final Account account, final Jid with, final Set<Media> media) {
         synchronized (this.rtpSessionProposals) {
@@ -390,6 +383,8 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                         this.connections.put(id, rtpConnection);
                         rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
                         rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
+
+                        CallIntegrationConnectionService.addNewIncomingCall(getXmppConnectionService(), id);
                         // TODO actually do the automatic accept?!
                     } else {
                         Log.d(
@@ -439,6 +434,8 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                     this.connections.put(id, rtpConnection);
                     rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
                     rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
+
+                    CallIntegrationConnectionService.addNewIncomingCall(getXmppConnectionService(), id);
                 }
             } else {
                 Log.d(
@@ -457,7 +454,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                 if (proposal != null) {
                     rtpSessionProposals.remove(proposal);
                     final JingleRtpConnection rtpConnection =
-                            new JingleRtpConnection(this, id, account.getJid());
+                            new JingleRtpConnection(this, id, account.getJid(), proposal.callIntegration);
                     rtpConnection.setProposedMedia(proposal.media);
                     this.connections.put(id, rtpConnection);
                     rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
@@ -490,6 +487,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                     getRtpSessionProposal(account, from.asBareJid(), sessionId);
             synchronized (rtpSessionProposals) {
                 if (proposal != null && rtpSessionProposals.remove(proposal) != null) {
+                    proposal.callIntegration.busy();
                     writeLogMissedOutgoing(
                             account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
                     toneManager.transition(RtpEndUserState.DECLINED_OR_BUSY, proposal.media);
@@ -628,10 +626,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return Optional.absent();
     }
 
-    void finishConnection(final AbstractJingleConnection connection) {
-        this.connections.remove(connection.getId());
-    }
-
     void finishConnectionOrThrow(final AbstractJingleConnection connection) {
         final AbstractJingleConnection.Id id = connection.getId();
         if (this.connections.remove(id) == null) {
@@ -680,6 +674,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                         + ": retracting rtp session proposal with "
                         + rtpSessionProposal.with);
         this.rtpSessionProposals.remove(rtpSessionProposal);
+        rtpSessionProposal.callIntegration.retracted();
         final MessagePacket messagePacket =
                 mXmppConnectionService.getMessageGenerator().sessionRetract(rtpSessionProposal);
         writeLogMissedOutgoing(
@@ -691,7 +686,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         mXmppConnectionService.sendMessagePacket(account, messagePacket);
     }
 
-    public String initializeRtpSession(
+    public JingleRtpConnection initializeRtpSession(
             final Account account, final Jid with, final Set<Media> media) {
         final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with);
         final JingleRtpConnection rtpConnection =
@@ -699,15 +694,15 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         rtpConnection.setProposedMedia(media);
         this.connections.put(id, rtpConnection);
         rtpConnection.sendSessionInitiate();
-        return id.sessionId;
+        return rtpConnection;
     }
 
-    public void proposeJingleRtpSession(
+    public RtpSessionProposal proposeJingleRtpSession(
             final Account account, final Jid with, final Set<Media> media) {
         synchronized (this.rtpSessionProposals) {
-            for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
+            for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
                     this.rtpSessionProposals.entrySet()) {
-                RtpSessionProposal proposal = entry.getKey();
+                final RtpSessionProposal proposal = entry.getKey();
                 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
                     final DeviceDiscoveryState preexistingState = entry.getValue();
                     if (preexistingState != null
@@ -716,7 +711,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                         toneManager.transition(endUserState, media);
                         mXmppConnectionService.notifyJingleRtpConnectionUpdate(
                                 account, with, proposal.sessionId, endUserState);
-                        return;
+                        return proposal;
                     }
                 }
             }
@@ -725,19 +720,23 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                     Log.d(
                             Config.LOGTAG,
                             "ignoring request to propose jingle session because the other party already created one for us");
-                    return;
+                    // TODO return something that we can parse the connection of of
+                    return null;
                 }
                 throw new IllegalStateException(
                         "There is already a running RTP session. This should have been caught by the UI");
             }
+            final CallIntegration callIntegration = new CallIntegration(mXmppConnectionService.getApplicationContext());
+            callIntegration.setInitialAudioDevice(CallIntegration.initialAudioDevice(media));
             final RtpSessionProposal proposal =
-                    RtpSessionProposal.of(account, with.asBareJid(), media);
+                    RtpSessionProposal.of(account, with.asBareJid(), media, callIntegration);
             this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
             mXmppConnectionService.notifyJingleRtpConnectionUpdate(
                     account, proposal.with, proposal.sessionId, RtpEndUserState.FINDING_DEVICE);
             final MessagePacket messagePacket =
                     mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
             mXmppConnectionService.sendMessagePacket(account, messagePacket);
+            return proposal;
         }
     }
 
@@ -826,6 +825,21 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return null;
     }
 
+    public JingleRtpConnection findJingleRtpConnection(final Account account, final Jid with) {
+        for (final AbstractJingleConnection connection : this.connections.values()) {
+            if (connection instanceof JingleRtpConnection rtpConnection) {
+                if (rtpConnection.isTerminated()) {
+                    continue;
+                }
+                final var id = rtpConnection.getId();
+                if (id.account == account && account.getJid().equals(with)) {
+                    return rtpConnection;
+                }
+            }
+        }
+        return null;
+    }
+
     private void resendSessionProposals(final Account account) {
         synchronized (this.rtpSessionProposals) {
             for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
@@ -865,7 +879,10 @@ public class JingleConnectionManager extends AbstractConnectionManager {
             }
             this.rtpSessionProposals.put(sessionProposal, target);
             final RtpEndUserState endUserState = target.toEndUserState();
-            toneManager.transition(endUserState, sessionProposal.media);
+            if (endUserState == RtpEndUserState.RINGING) {
+                sessionProposal.callIntegration.setDialing();
+            }
+            //toneManager.transition(endUserState, sessionProposal.media);
             mXmppConnectionService.notifyJingleRtpConnectionUpdate(
                     account, sessionProposal.with, sessionProposal.sessionId, endUserState);
             Log.d(
@@ -994,16 +1011,18 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         public final String sessionId;
         public final Set<Media> media;
         private final Account account;
+        private final CallIntegration callIntegration;
 
-        private RtpSessionProposal(Account account, Jid with, String sessionId, Set<Media> media) {
+        private RtpSessionProposal(Account account, Jid with, String sessionId, Set<Media> media, final CallIntegration callIntegration) {
             this.account = account;
             this.with = with;
             this.sessionId = sessionId;
             this.media = media;
+            this.callIntegration = callIntegration;
         }
 
-        public static RtpSessionProposal of(Account account, Jid with, Set<Media> media) {
-            return new RtpSessionProposal(account, with, nextRandomId(), media);
+        public static RtpSessionProposal of(Account account, Jid with, Set<Media> media, final CallIntegration callIntegration) {
+            return new RtpSessionProposal(account, with, nextRandomId(), media,callIntegration);
         }
 
         @Override
@@ -1035,5 +1054,9 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         public String getSessionId() {
             return sessionId;
         }
+
+        public CallIntegration getCallIntegration() {
+            return this.callIntegration;
+        }
     }
 }
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
index 80d7d2118b803323c8606b2486bb2e89aab02861..8d5aa8dfd00ca772078889690f532b535e137756 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
@@ -1,5 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import android.telecom.Call;
+import android.telecom.TelecomManager;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -12,13 +14,11 @@ import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -34,7 +34,7 @@ import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.services.AppRTCAudioManager;
-import eu.siacs.conversations.utils.IP;
+import eu.siacs.conversations.services.CallIntegration;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
@@ -67,7 +67,7 @@ import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
 public class JingleRtpConnection extends AbstractJingleConnection
-        implements WebRTCWrapper.EventCallback {
+        implements WebRTCWrapper.EventCallback, CallIntegration.Callback {
 
     public static final List<State> STATES_SHOWING_ONGOING_CALL =
             Arrays.asList(
@@ -78,6 +78,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private final Queue<Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>>>
             pendingIceCandidates = new LinkedList<>();
     private final OmemoVerification omemoVerification = new OmemoVerification();
+    private final CallIntegration callIntegration;
     private final Message message;
 
     private Set<Media> proposedMedia;
@@ -90,7 +91,13 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
     private ScheduledFuture<?> ringingTimeoutFuture;
 
-    JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
+    JingleRtpConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator) {
+        this(jingleConnectionManager, id, initiator, new CallIntegration(jingleConnectionManager.getXmppConnectionService().getApplicationContext()));
+        this.callIntegration.setAddress(CallIntegration.address(id.with.asBareJid()), TelecomManager.PRESENTATION_ALLOWED);
+        this.callIntegration.setInitialized();
+    }
+
+    JingleRtpConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator, final CallIntegration callIntegration) {
         super(jingleConnectionManager, id, initiator);
         final Conversation conversation =
                 jingleConnectionManager
@@ -102,6 +109,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
                         isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
                         Message.TYPE_RTP_SESSION,
                         id.sessionId);
+        this.callIntegration = callIntegration;
+        this.callIntegration.setCallback(this);
     }
 
     @Override
@@ -1158,6 +1167,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             target = State.SESSION_INITIALIZED_PRE_APPROVED;
         } else {
             target = State.SESSION_INITIALIZED;
+            setProposedMedia(contentMap.getMedia());
         }
         if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
             respondOk(jinglePacket);
@@ -1628,7 +1638,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                                     + from
                                     + " for "
                                     + media);
-                    this.proposedMedia = Sets.newHashSet(media);
+                    this.setProposedMedia(Sets.newHashSet(media));
                 })) {
             if (serverMsgId != null) {
                 this.message.setServerMsgId(serverMsgId);
@@ -1648,6 +1658,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     }
 
     private void startRinging() {
+        this.callIntegration.setRinging();
         Log.d(
                 Config.LOGTAG,
                 id.account.getJid().asBareJid()
@@ -1657,6 +1668,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
         ringingTimeoutFuture =
                 jingleConnectionManager.schedule(
                         this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
+        if (CallIntegration.selfManaged()) {
+            return;
+        }
         xmppConnectionService.getNotificationService().startRinging(id, getMedia());
     }
 
@@ -2054,6 +2068,56 @@ public class JingleRtpConnection extends AbstractJingleConnection
         };
     }
 
+    private boolean isPeerConnectionConnected() {
+        try {
+            return webRTCWrapper.getState() == PeerConnection.PeerConnectionState.CONNECTED;
+        } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
+            return false;
+        }
+    }
+
+    private void updateCallIntegrationState() {
+        switch (this.state) {
+            case NULL, PROPOSED, SESSION_INITIALIZED -> {
+                if (isInitiator()) {
+                    this.callIntegration.setDialing();
+                } else {
+                    this.callIntegration.setRinging();
+                }
+            }
+            case PROCEED, SESSION_INITIALIZED_PRE_APPROVED -> {
+                if (isInitiator()) {
+                    this.callIntegration.setDialing();
+                } else {
+                    this.callIntegration.setInitialized();
+                }
+            }
+            case SESSION_ACCEPTED -> {
+                if (isPeerConnectionConnected()) {
+                    this.callIntegration.setActive();
+                } else {
+                    this.callIntegration.setInitialized();
+                }
+            }
+            case REJECTED, REJECTED_RACED, TERMINATED_DECLINED_OR_BUSY -> {
+                if (isInitiator()) {
+                    this.callIntegration.busy();
+                } else {
+                    this.callIntegration.rejected();
+                }
+            }
+            case TERMINATED_SUCCESS -> this.callIntegration.success();
+            case ACCEPTED -> this.callIntegration.accepted();
+            case RETRACTED, RETRACTED_RACED, TERMINATED_CANCEL_OR_TIMEOUT -> this.callIntegration
+                    .retracted();
+            case TERMINATED_CONNECTIVITY_ERROR,
+                    TERMINATED_APPLICATION_FAILURE,
+                    TERMINATED_SECURITY_ERROR -> this.callIntegration.error();
+            default -> throw new IllegalStateException(
+                    String.format("%s is not handled", this.state));
+        }
+    }
+
     public ContentAddition getPendingContentAddition() {
         final RtpContentMap in = this.incomingContentAdd;
         final RtpContentMap out = this.outgoingContentAdd;
@@ -2135,15 +2199,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
-    public void notifyPhoneCall() {
-        Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
-        if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
-            rejectCall();
-        } else {
-            endCall();
-        }
-    }
-
     public synchronized void rejectCall() {
         if (isTerminated()) {
             Log.w(
@@ -2537,8 +2592,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
         final RtpContentMap activeContents = rtpContentMap.activeContents();
         setLocalContentMap(activeContents);
-        this.webRTCWrapper.switchSpeakerPhonePreference(
-                AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia()));
+        // TODO change audio device on callIntegration was (`switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia())`)
         updateEndUserState();
     }
 
@@ -2571,8 +2625,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
         return this.sessionDuration.elapsed(TimeUnit.MILLISECONDS);
     }
 
-    public AppRTCAudioManager getAudioManager() {
-        return webRTCWrapper.getAudioManager();
+
+    public CallIntegration getCallIntegration() {
+        return this.callIntegration;
     }
 
     public boolean isMicrophoneEnabled() {
@@ -2603,10 +2658,26 @@ public class JingleRtpConnection extends AbstractJingleConnection
         return webRTCWrapper.switchCamera();
     }
 
+    @Override
+    public void onCallIntegrationShowIncomingCallUi() {
+        xmppConnectionService.getNotificationService().startRinging(id, getMedia());
+    }
+
+    @Override
+    public void onCallIntegrationDisconnect() {
+        Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
+        if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
+            rejectCall();
+        } else {
+            endCall();
+        }
+    }
+
     @Override
     public void onAudioDeviceChanged(
-            AppRTCAudioManager.AudioDevice selectedAudioDevice,
-            Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+            final CallIntegration.AudioDevice selectedAudioDevice,
+            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
+        Log.d(Config.LOGTAG,"onAudioDeviceChanged("+selectedAudioDevice+","+availableAudioDevices+")");
         xmppConnectionService.notifyJingleRtpConnectionUpdate(
                 selectedAudioDevice, availableAudioDevices);
     }
@@ -2614,6 +2685,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private void updateEndUserState() {
         final RtpEndUserState endUserState = getEndUserState();
         jingleConnectionManager.toneManager.transition(isInitiator(), endUserState, getMedia());
+        this.updateCallIntegrationState();
         xmppConnectionService.notifyJingleRtpConnectionUpdate(
                 id.account, id.with, id.sessionId, endUserState);
     }
@@ -2670,6 +2742,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     protected void finish() {
         if (isTerminated()) {
             this.cancelRingingTimeout();
+            this.callIntegration.verifyDisconnected();
             this.webRTCWrapper.verifyClosed();
             this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
             super.finish();
@@ -2724,6 +2797,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
     void setProposedMedia(final Set<Media> media) {
         this.proposedMedia = media;
+        this.callIntegration.setInitialAudioDevice(CallIntegration.initialAudioDevice(media));
     }
 
     public void fireStateUpdate() {
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
index 24ed790ddadc1b72ee560555f2fe7abea2023b43..885820460a60897eb0853549d8631852cf07ab64 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
@@ -9,7 +9,7 @@ public enum RtpEndUserState {
     FINDING_DEVICE, //'propose' has been sent out; no 184 ack yet
     RINGING, //'propose' has been sent out and it has been 184 acked
     ACCEPTING_CALL, //'proceed' message has been sent; but no session-initiate has been received
-    ENDING_CALL, //libwebrt says 'closed' but session-terminate hasnt gone through
+    ENDING_CALL, //libwebrt says 'closed' but session-terminate has not gone through
     ENDED, //close UI
     DECLINED_OR_BUSY, //other party declined; no retry button
     CONNECTIVITY_ERROR, //network error; retry button
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java
index da5b9ab2bc0e9899e0b19a9e8a837c2265c0e9d2..fb82b7219254589c30a5c3c4434479e83032939b 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java
@@ -89,7 +89,8 @@ class ToneManager {
         }
         switch (state) {
             case RINGING:
-                scheduleWaitingTone();
+                // ringing can be removed as this is now handled by 'CallIntegration'
+                //scheduleWaitingTone();
                 break;
             case CONNECTED:
                 scheduleConnected();
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
index cb0c8579d046f4ec9bfaf530a752a7da89ec1137..fa504ed191e5b149c040cf569cec20feba487106 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
@@ -16,6 +16,7 @@ import com.google.common.util.concurrent.SettableFuture;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.services.AppRTCAudioManager;
+import eu.siacs.conversations.services.CallIntegration;
 import eu.siacs.conversations.services.XmppConnectionService;
 
 import org.webrtc.AudioSource;
@@ -83,16 +84,6 @@ public class WebRTCWrapper {
     private final EventCallback eventCallback;
     private final AtomicBoolean readyToReceivedIceCandidates = new AtomicBoolean(false);
     private final Queue<IceCandidate> iceCandidates = new LinkedList<>();
-    private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents =
-            new AppRTCAudioManager.AudioManagerEvents() {
-                @Override
-                public void onAudioDeviceChanged(
-                        AppRTCAudioManager.AudioDevice selectedAudioDevice,
-                        Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
-                    eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
-                }
-            };
-    private final Handler mainHandler = new Handler(Looper.getMainLooper());
     private TrackWrapper<AudioTrack> localAudioTrack = null;
     private TrackWrapper<VideoTrack> localVideoTrack = null;
     private VideoTrack remoteVideoTrack = null;
@@ -214,7 +205,6 @@ public class WebRTCWrapper {
             };
     @Nullable private PeerConnectionFactory peerConnectionFactory = null;
     @Nullable private PeerConnection peerConnection = null;
-    private AppRTCAudioManager appRTCAudioManager = null;
     private ToneManager toneManager = null;
     private Context context = null;
     private EglBase eglBase = null;
@@ -251,15 +241,6 @@ public class WebRTCWrapper {
         }
         this.context = service;
         this.toneManager = service.getJingleConnectionManager().toneManager;
-        mainHandler.post(
-                () -> {
-                    appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
-                    toneManager.setAppRtcAudioManagerHasControl(true);
-                    appRTCAudioManager.start(audioManagerEvents);
-                    eventCallback.onAudioDeviceChanged(
-                            appRTCAudioManager.getSelectedAudioDevice(),
-                            appRTCAudioManager.getAudioDevices());
-                });
     }
 
     synchronized void initializePeerConnection(
@@ -462,16 +443,11 @@ public class WebRTCWrapper {
         final PeerConnection peerConnection = this.peerConnection;
         final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
         final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
-        final AppRTCAudioManager audioManager = this.appRTCAudioManager;
         final EglBase eglBase = this.eglBase;
         if (peerConnection != null) {
             this.peerConnection = null;
             dispose(peerConnection);
         }
-        if (audioManager != null) {
-            toneManager.setAppRtcAudioManagerHasControl(false);
-            mainHandler.post(audioManager::stop);
-        }
         this.localVideoTrack = null;
         this.remoteVideoTrack = null;
         if (videoSourceWrapper != null) {
@@ -498,8 +474,8 @@ public class WebRTCWrapper {
                 || this.eglBase != null
                 || this.localVideoTrack != null
                 || this.remoteVideoTrack != null) {
-            final IllegalStateException e =
-                    new IllegalStateException("WebRTCWrapper hasn't been closed properly");
+            final AssertionError e =
+                    new AssertionError("WebRTCWrapper hasn't been closed properly");
             Log.e(Config.LOGTAG, "verifyClosed() failed. Going to throw", e);
             throw e;
         }
@@ -750,27 +726,15 @@ public class WebRTCWrapper {
         return context;
     }
 
-    AppRTCAudioManager getAudioManager() {
-        return appRTCAudioManager;
-    }
-
     void execute(final Runnable command) {
         this.executorService.execute(command);
     }
 
-    public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
-        mainHandler.post(() -> appRTCAudioManager.switchSpeakerPhonePreference(preference));
-    }
-
     public interface EventCallback {
         void onIceCandidate(IceCandidate iceCandidate);
 
         void onConnectionChange(PeerConnection.PeerConnectionState newState);
 
-        void onAudioDeviceChanged(
-                AppRTCAudioManager.AudioDevice selectedAudioDevice,
-                Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
-
         void onRenegotiationNeeded();
     }