diff --git a/jami-android/app/src/main/AndroidManifest.xml b/jami-android/app/src/main/AndroidManifest.xml
index 2f42d90ae14f18313e71d2ad7c57cb5109483b0d..affc895f0aba249dddc92b33eecd8e5ac7214b74 100644
--- a/jami-android/app/src/main/AndroidManifest.xml
+++ b/jami-android/app/src/main/AndroidManifest.xml
@@ -31,10 +31,10 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.READ_CONTACTS" />
+    <uses-permission android:name="android.permission.WRITE_CONTACTS" />
     <uses-permission android:name="android.permission.READ_PROFILE" />
     <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
     <uses-permission android:name="android.permission.VIBRATE" />
-    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.CAMERA" />
     <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
@@ -44,6 +44,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
     <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
 
     <uses-feature
         android:name="android.hardware.wifi"
@@ -286,6 +287,15 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
                 <data android:mimeType="vnd.android.cursor.item/person" />
             </intent-filter>
         </activity>
+
+        <service android:name=".service.ConnectionService"
+            android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.telecom.ConnectionService" />
+            </intent-filter>
+        </service>
+
         <activity
             android:name=".client.ConversationActivity"
             android:allowEmbedded="true"
diff --git a/jami-android/app/src/main/java/cx/ring/application/JamiApplication.kt b/jami-android/app/src/main/java/cx/ring/application/JamiApplication.kt
index cae45d4148ce43555a40f4a152f2db9013c20390..db2eaac74b46eb4a840b5f817696057a12a291de 100644
--- a/jami-android/app/src/main/java/cx/ring/application/JamiApplication.kt
+++ b/jami-android/app/src/main/java/cx/ring/application/JamiApplication.kt
@@ -29,12 +29,16 @@ import android.os.Build
 import android.os.Bundle
 import android.os.IBinder
 import android.system.Os
+import android.telecom.PhoneAccount
+import android.telecom.PhoneAccountHandle
+import android.telecom.TelecomManager
 import android.util.Log
 import android.view.WindowManager
 import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
 import com.bumptech.glide.Glide
-import com.google.android.material.color.DynamicColors
 import cx.ring.BuildConfig
+import cx.ring.service.ConnectionService
 import cx.ring.R
 import cx.ring.service.DRingService
 import cx.ring.service.JamiJobService
@@ -50,6 +54,7 @@ import java.util.concurrent.ScheduledExecutorService
 import javax.inject.Inject
 import javax.inject.Named
 
+
 abstract class JamiApplication : Application() {
     companion object {
         private val TAG = JamiApplication::class.java.simpleName
@@ -98,6 +103,8 @@ abstract class JamiApplication : Application() {
     abstract val pushToken: String
     abstract val pushPlatform: String
 
+    lateinit var androidPhoneAccountHandle: PhoneAccountHandle
+
     open fun activityInit(activityContext: Context) {}
 
     private var mBound = false
@@ -212,6 +219,24 @@ abstract class JamiApplication : Application() {
             RxJavaPlugins.setErrorHandler { e -> Log.e(TAG, "Unhandled RxJava error", e) }
         }
 
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            getSystemService<TelecomManager>()?.let { telecomService ->
+                val componentName = ComponentName(this, ConnectionService::class.java)
+                val handle = PhoneAccountHandle(componentName, ConnectionService.HANDLE_ID)
+                androidPhoneAccountHandle = handle
+                //telecomService.unregisterPhoneAccount(handle)
+                telecomService.registerPhoneAccount(PhoneAccount.Builder(handle, getString(R.string.app_name))
+                    .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
+                    //.setCapabilities(PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING)
+                    .setHighlightColor(getColor(R.color.color_primary_dark))
+                    .addSupportedUriScheme("ring")
+                    .addSupportedUriScheme("jami")
+                    .addSupportedUriScheme("swarm")
+                    .addSupportedUriScheme(PhoneAccount.SCHEME_SIP)
+                    .build())
+            }
+        }
+
         bootstrapDaemon()
         mPreferencesService.loadDarkMode()
         Completable.fromAction {
diff --git a/jami-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt b/jami-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt
index 71326125cbad46e54c6f147746ff21578fab1a6b..ff27fb1db6e47f5eae809d1cc63ba0c262c5560b 100755
--- a/jami-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt
+++ b/jami-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt
@@ -64,8 +64,9 @@ object ServiceInjectionModule {
     fun provideNotificationService(@ApplicationContext appContext: Context, accountService: AccountService,
                                    contactService: ContactService,
                                    preferencesService: PreferencesService,
-                                   deviceRuntimeService: DeviceRuntimeService): NotificationService {
-        return NotificationServiceImpl(appContext, accountService, contactService, preferencesService, deviceRuntimeService)
+                                   deviceRuntimeService: DeviceRuntimeService,
+                                   callService: CallService): NotificationService {
+        return NotificationServiceImpl(appContext, accountService, contactService, preferencesService, deviceRuntimeService, callService)
     }
 
     @Provides
@@ -90,10 +91,12 @@ object ServiceInjectionModule {
 
     @Provides
     @Singleton
-    fun provideCallService(@Named("DaemonExecutor") executor : ScheduledExecutorService,
+    fun provideCallService(@ApplicationContext appContext: Context,
+                           @Named("DaemonExecutor") executor : ScheduledExecutorService,
                            contactService: ContactService,
-                           accountService: AccountService): CallService {
-        return CallService(executor, contactService, accountService)
+                           accountService: AccountService,
+                           deviceRuntimeService: DeviceRuntimeService): CallService {
+        return CallServiceImpl(appContext, executor, contactService, accountService, deviceRuntimeService)
     }
 
     @Provides
@@ -118,9 +121,8 @@ object ServiceInjectionModule {
     @Singleton
     fun provideContactService(@ApplicationContext appContext: Context,
                               preferenceService: PreferencesService,
-                              deviceRuntimeService : DeviceRuntimeService,
                               accountService: AccountService): ContactService {
-        return ContactServiceImpl(appContext, preferenceService, deviceRuntimeService, accountService)
+        return ContactServiceImpl(appContext, preferenceService, accountService)
     }
 
     @Provides
diff --git a/jami-android/app/src/main/java/cx/ring/fragments/CallFragment.kt b/jami-android/app/src/main/java/cx/ring/fragments/CallFragment.kt
index 06a48689071b07d98f4345cfdcddf7716fc469a3..cfd10c519acd5bb46ef71138753e604a04b61ff3 100644
--- a/jami-android/app/src/main/java/cx/ring/fragments/CallFragment.kt
+++ b/jami-android/app/src/main/java/cx/ring/fragments/CallFragment.kt
@@ -57,6 +57,7 @@ import android.widget.FrameLayout
 import android.widget.RelativeLayout
 import android.widget.Toast
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
 import androidx.core.view.*
 import androidx.databinding.DataBindingUtil
 import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -763,7 +764,7 @@ class CallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView,
     }
 
     override fun updateAudioState(state: AudioState) {
-        binding!!.callSpeakerBtn.isChecked = state.outputType == HardwareService.AudioOutput.SPEAKERS
+        binding!!.callSpeakerBtn.isChecked = state.output.type == HardwareService.AudioOutputType.SPEAKERS
     }
 
     override fun updateTime(duration: Long) {
@@ -946,7 +947,6 @@ class CallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView,
                 setImageResource(if (hasMultipleCamera && hasActiveVideo) R.drawable.baseline_flip_camera_24 else R.drawable.baseline_flip_camera_24_off)
             }
             callMicBtn.isChecked = isMicrophoneMuted
-            callSpeakerBtn.isChecked = isSpeakerOn
         }
     }
 
@@ -1346,7 +1346,6 @@ class CallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView,
 
     companion object {
         val TAG = CallFragment::class.simpleName!!
-        const val ACTION_PLACE_CALL = "PLACE_CALL"
         const val KEY_ACTION = "action"
         const val KEY_HAS_VIDEO = "HAS_VIDEO"
         private const val REQUEST_CODE_ADD_PARTICIPANT = 6
diff --git a/jami-android/app/src/main/java/cx/ring/service/CallConnection.kt b/jami-android/app/src/main/java/cx/ring/service/CallConnection.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff56926fa9aadbcbc542dc3812e2dac5efd7ffda
--- /dev/null
+++ b/jami-android/app/src/main/java/cx/ring/service/CallConnection.kt
@@ -0,0 +1,183 @@
+/*
+ *  Copyright (C) 2004-2023 Savoir-faire Linux Inc.
+ *
+ *  This program is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  This program is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package cx.ring.service
+
+import android.os.Build
+import android.telecom.CallAudioState
+import android.telecom.Connection
+import android.telecom.ConnectionRequest
+import android.telecom.DisconnectCause
+import android.telecom.VideoProfile
+import android.util.Log
+import androidx.annotation.RequiresApi
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.subjects.BehaviorSubject
+import io.reactivex.rxjava3.subjects.Subject
+import net.jami.model.Call
+
+enum class CallRequestResult {
+    ACCEPTED,
+    ACCEPTED_VIDEO,
+    REJECTED,
+    IGNORED,
+    SHOW_UI
+}
+
+/**
+ * Implements a Connection from the Android Telecom API.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+class CallConnection(
+    val service: ConnectionService,
+    val request: ConnectionRequest,
+    private val showIncomingCallUi: ((CallConnection, CallRequestResult) -> Unit)?
+) : Connection() {
+
+    private val audioStateSubject: Subject<CallAudioState> = BehaviorSubject.create()
+    private val wantedAudioStateSubject: Subject<List<Int>> = BehaviorSubject.create()
+
+    val audioState: Observable<CallAudioState>
+        get() = audioStateSubject
+
+    var call: Call? = null
+        set(value) {
+            field = value
+            disposable.clear()
+            if (value != null) {
+                if (value.callStatus == Call.CallStatus.RINGING)
+                    setRinging()
+                disposable.add(service.callService.callsUpdates
+                    .filter { it === value }
+                    .subscribe { call ->
+                        val status = call.callStatus
+                        // Set the HOLD capability if the call is current
+                        connectionCapabilities = if (status == Call.CallStatus.CURRENT) {
+                            connectionCapabilities or CAPABILITY_HOLD
+                        } else {
+                            connectionCapabilities and CAPABILITY_HOLD.inv()
+                        }
+                        if (status == Call.CallStatus.CURRENT)
+                            callAudioState?.let { audioStateSubject.onNext(it) }
+                        // Update call status
+                        when (status) {
+                            Call.CallStatus.RINGING -> if (call.isIncoming) setRinging() else setDialing()
+                            Call.CallStatus.CURRENT -> setActive()
+                            Call.CallStatus.HOLD -> setOnHold()
+                            Call.CallStatus.INACTIVE -> setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
+                            Call.CallStatus.FAILURE -> setDisconnected(DisconnectCause(DisconnectCause.ERROR))
+                            Call.CallStatus.HUNGUP -> setDisconnected(DisconnectCause(DisconnectCause.REMOTE))
+                            Call.CallStatus.OVER -> dispose()
+                            else -> {}
+                        }
+                    })
+                disposable.add(Observable
+                    .combineLatest(audioStateSubject, wantedAudioStateSubject) { a, w -> Pair(a, w) }
+                    .subscribe { (audioState, wantedList) ->
+                        val supported = audioState.supportedRouteMask
+                        wantedList.firstOrNull { it and supported != 0 }?.let {
+                            setAudioRoute(it)
+                        }
+                    })
+            }
+        }
+    val disposable = CompositeDisposable()
+
+    fun dispose() {
+        disposable.dispose()
+        destroy()
+    }
+
+    override fun onAbort() {
+        Log.w(TAG, "onAbort")
+        service.callService.hangUp(call!!.account!!, call!!.daemonIdString!!)
+    }
+
+    override fun onAnswer(videoState: Int) {
+        Log.w(TAG, "onAnswer $videoState")
+        showIncomingCallUi?.invoke(this, if (videoState == VideoProfile.STATE_BIDIRECTIONAL) CallRequestResult.ACCEPTED_VIDEO else CallRequestResult.ACCEPTED)
+    }
+
+    override fun onReject() {
+        Log.w(TAG, "onReject")
+        showIncomingCallUi?.invoke(this, CallRequestResult.REJECTED)
+    }
+
+    override fun onHold() {
+        Log.w(TAG, "onHold")
+        service.callService.hold(call!!.account!!, call!!.daemonIdString!!)
+    }
+
+    override fun onUnhold() {
+        Log.w(TAG, "onUnhold")
+        service.callService.unhold(call!!.account!!, call!!.daemonIdString!!)
+    }
+
+    override fun onSilence() {
+        Log.w(TAG, "onSilence")
+    }
+
+    override fun onDisconnect() {
+        Log.w(TAG, "onDisconnect")
+        service.callService.hangUp(call!!.account!!, call!!.daemonIdString!!)
+    }
+
+    override fun onPlayDtmfTone(c: Char) {
+        Log.w(TAG, "onPlayDtmfTone $c")
+        service.callService.playDtmf(c.toString())
+    }
+
+    override fun onCallAudioStateChanged(state: CallAudioState) {
+        Log.w(TAG, "onCallAudioStateChanged: $state")
+        audioStateSubject.onNext(state)
+    }
+
+    override fun onShowIncomingCallUi() {
+        Log.w(TAG, "onShowIncomingCallUi")
+        showIncomingCallUi?.invoke(this, CallRequestResult.SHOW_UI)
+    }
+
+    fun setWantedAudioState(wanted: List<Int>) {
+        wantedAudioStateSubject.onNext(wanted)
+    }
+
+    companion object {
+        private val TAG: String = CallConnection::class.java.simpleName
+
+        /** Default route list for audio calls */
+        val ROUTE_LIST_DEFAULT = listOf(
+            CallAudioState.ROUTE_BLUETOOTH,
+            CallAudioState.ROUTE_WIRED_HEADSET,
+            CallAudioState.ROUTE_WIRED_OR_EARPIECE,
+            CallAudioState.ROUTE_SPEAKER
+        )
+        /** Default route list for ringtone and video calls */
+        val ROUTE_LIST_SPEAKER_IMPLICIT = listOf(
+            CallAudioState.ROUTE_BLUETOOTH,
+            CallAudioState.ROUTE_WIRED_HEADSET,
+            CallAudioState.ROUTE_SPEAKER,
+            CallAudioState.ROUTE_WIRED_OR_EARPIECE
+        )
+        /** Route list when the user selects the speaker explicitly */
+        val ROUTE_LIST_SPEAKER_EXPLICIT = listOf(
+            CallAudioState.ROUTE_SPEAKER,
+            CallAudioState.ROUTE_BLUETOOTH,
+            CallAudioState.ROUTE_WIRED_HEADSET,
+            CallAudioState.ROUTE_WIRED_OR_EARPIECE
+        )
+    }
+}
\ No newline at end of file
diff --git a/jami-android/app/src/main/java/cx/ring/service/ConnectionService.kt b/jami-android/app/src/main/java/cx/ring/service/ConnectionService.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0dafd7b5adcfe5d3dfc980ea2276e6a04f04be23
--- /dev/null
+++ b/jami-android/app/src/main/java/cx/ring/service/ConnectionService.kt
@@ -0,0 +1,125 @@
+/*
+ *  Copyright (C) 2004-2023 Savoir-faire Linux Inc.
+ *
+ *  This program is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  This program is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package cx.ring.service
+
+import android.content.ContentProviderOperation
+import android.content.ContentResolver
+import android.content.OperationApplicationException
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.RemoteException
+import android.provider.ContactsContract
+import android.telecom.Connection
+import android.telecom.ConnectionRequest
+import android.telecom.ConnectionService
+import android.telecom.PhoneAccountHandle
+import android.telecom.TelecomManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import cx.ring.services.CallServiceImpl
+import cx.ring.services.DeviceRuntimeServiceImpl
+import cx.ring.utils.ConversationPath
+import dagger.hilt.android.AndroidEntryPoint
+import net.jami.model.Uri
+import net.jami.services.CallService
+import net.jami.services.ContactService
+import net.jami.services.ConversationFacade
+import net.jami.services.DeviceRuntimeService
+import net.jami.services.NotificationService
+import java.io.ByteArrayOutputStream
+import javax.inject.Inject
+
+@RequiresApi(Build.VERSION_CODES.O)
+@AndroidEntryPoint
+class ConnectionService : ConnectionService() {
+    @Inject
+    lateinit var callService: CallService
+    @Inject
+    lateinit var contactService: ContactService
+    @Inject
+    lateinit var conversationFacade: ConversationFacade
+    @Inject
+    lateinit var notificationService: NotificationService
+    @Inject
+    lateinit var deviceRuntimeService: DeviceRuntimeService
+
+    private fun buildConnection(request: ConnectionRequest, showIncomingCallUi: ((CallConnection) -> Unit)? = null): CallConnection {
+        val connection = CallConnection(this, request, showIncomingCallUi).apply {
+            val account = request.extras.getString(ConversationPath.KEY_ACCOUNT_ID)
+            val contactId = request.extras.getString(ConversationPath.KEY_CONVERSATION_URI)
+            if (account != null && contactId != null) {
+                val profile = conversationFacade.observeConversation(account, Uri.fromString(contactId), false).blockingFirst()
+                Log.w(TAG, "Set connection metadata ${profile.title} ${android.net.Uri.parse(profile.uriTitle)}")
+                setCallerDisplayName(profile.title, TelecomManager.PRESENTATION_ALLOWED)
+                setAddress(android.net.Uri.parse(profile.uriTitle), TelecomManager.PRESENTATION_UNKNOWN)
+            } else
+                setAddress(request.address, TelecomManager.PRESENTATION_UNKNOWN)
+
+            audioModeIsVoip = true
+            connectionCapabilities = getCapabilities()
+            connectionProperties = getProperties()
+        }
+        return connection
+    }
+
+    override fun onCreateOutgoingConnection(account: PhoneAccountHandle?, request: ConnectionRequest): Connection {
+        val connection = buildConnection(request)
+        (callService as CallServiceImpl).onPlaceCallResult(request.address, request.extras, connection)
+        return connection
+    }
+
+    override fun onCreateOutgoingConnectionFailed(account: PhoneAccountHandle?, request: ConnectionRequest) {
+        Log.w(TAG, "onCreateOutgoingConnectionFailed $request")
+        (callService as CallServiceImpl).onPlaceCallResult(request.address, request.extras, null)
+    }
+
+    override fun onCreateIncomingConnection(account: PhoneAccountHandle?, request: ConnectionRequest): Connection {
+        Log.w(TAG, "onCreateIncomingConnection $request")
+        val connection = buildConnection(request) {
+            (callService as CallServiceImpl).onIncomingCallResult(request.extras, it)
+        }
+        return connection
+    }
+
+    override fun onCreateIncomingConnectionFailed(account: PhoneAccountHandle?, request: ConnectionRequest?) {
+        Log.w(TAG, "onCreateIncomingConnectionFailed $request")
+        if (request != null) {
+            (callService as CallServiceImpl).onIncomingCallResult(request.extras, null)
+        }
+    }
+
+    companion object {
+        private val TAG: String = ConnectionService::class.java.simpleName
+        const val HANDLE_ID = "jami"
+
+        private const val CAPABILITIES = Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION or
+                Connection.CAPABILITY_CAN_PAUSE_VIDEO or
+                Connection.CAPABILITY_SUPPORT_HOLD or
+                Connection.CAPABILITY_MUTE /*or
+                Connection.CAPABILITY_DISCONNECT_FROM_CONFERENCE or
+                Connection.CAPABILITY_SEPARATE_FROM_CONFERENCE */
+        private const val PROPERTIES = Connection.PROPERTY_SELF_MANAGED
+
+        fun getCapabilities() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            CAPABILITIES or Connection.CAPABILITY_ADD_PARTICIPANT
+        } else CAPABILITIES
+
+        fun getProperties() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            PROPERTIES or Connection.PROPERTY_HIGH_DEF_AUDIO
+        } else PROPERTIES
+    }
+}
diff --git a/jami-android/app/src/main/java/cx/ring/service/DRingService.kt b/jami-android/app/src/main/java/cx/ring/service/DRingService.kt
index 352a68c735376cc36ed99f3e422d54ed8f0ccf8a..0e53b9269a6f305bff3c5761f3d90363be82ba54 100644
--- a/jami-android/app/src/main/java/cx/ring/service/DRingService.kt
+++ b/jami-android/app/src/main/java/cx/ring/service/DRingService.kt
@@ -41,9 +41,7 @@ import cx.ring.BuildConfig
 import cx.ring.application.JamiApplication
 import cx.ring.client.CallActivity
 import cx.ring.client.ConversationActivity
-import cx.ring.tv.call.TVCallActivity
 import cx.ring.utils.ConversationPath
-import cx.ring.utils.DeviceUtils
 import dagger.hilt.android.AndroidEntryPoint
 import io.reactivex.rxjava3.disposables.CompositeDisposable
 import net.jami.model.Conversation
@@ -158,10 +156,9 @@ class DRingService : Service() {
         if (mDeviceRuntimeService.hasContactPermission()) {
             contentResolver.registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, contactContentObserver)
         }
-        val intentFilter = IntentFilter()
-        intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-            intentFilter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
+        val intentFilter = IntentFilter().apply {
+            addAction(ConnectivityManager.CONNECTIVITY_ACTION)
+            addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
         }
         registerReceiver(receiver, intentFilter)
         updateConnectivityState()
diff --git a/jami-android/app/src/main/java/cx/ring/services/CallServiceImpl.kt b/jami-android/app/src/main/java/cx/ring/services/CallServiceImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..06d8ea023c880ab3d7e9d20ba1949f90810531aa
--- /dev/null
+++ b/jami-android/app/src/main/java/cx/ring/services/CallServiceImpl.kt
@@ -0,0 +1,147 @@
+package cx.ring.services
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.telecom.TelecomManager
+import android.telecom.VideoProfile
+import android.util.Log
+import androidx.core.content.getSystemService
+import cx.ring.application.JamiApplication
+import cx.ring.service.CallConnection
+import cx.ring.service.CallRequestResult
+import cx.ring.utils.ConversationPath
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.subjects.SingleSubject
+import net.jami.model.Call
+import net.jami.model.Media
+import net.jami.model.Uri
+import net.jami.services.AccountService
+import net.jami.services.CallService
+import net.jami.services.ContactService
+import net.jami.services.DeviceRuntimeService
+import net.jami.services.NotificationService
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ScheduledExecutorService
+
+class CallServiceImpl(val mContext: Context, executor: ScheduledExecutorService,
+                      contactService: ContactService,
+                      accountService: AccountService,
+                      deviceRuntimeService: DeviceRuntimeService
+): CallService(executor, contactService, accountService, deviceRuntimeService) {
+
+    private val pendingCallRequests = ConcurrentHashMap<String, SingleSubject<SystemCall>>()
+    private val incomingCallRequests = ConcurrentHashMap<String, Pair<Call, SingleSubject<SystemCall>>>()
+
+    class AndroidCall(val call: CallConnection?): SystemCall(call != null) {
+        override fun setCall(call: Call) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                this.call?.call = call
+                call.setSystemConnection(this)
+            } else {
+                call.setSystemConnection(null)
+            }
+        }
+
+    }
+
+    override fun requestPlaceCall(accountId: String, conversationUri: Uri?, contactUri: String, hasVideo: Boolean): Single<SystemCall> {
+        // Use the Android Telecom API to implement requestPlaceCall if available
+        mContext.getSystemService<TelecomManager>()?.let { telecomService ->
+            val accountHandle = JamiApplication.instance!!.androidPhoneAccountHandle
+
+            // Dismiss the call immediately if disallowed
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                if (!telecomService.isOutgoingCallPermitted(accountHandle))
+                    return CALL_DISALLOWED
+            }
+
+            // Build call parameters
+            val params = Bundle().apply {
+                putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accountHandle)
+                putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, Bundle().apply {
+                    putString(ConversationPath.KEY_ACCOUNT_ID, accountId)
+                    putString(ConversationPath.KEY_CONVERSATION_URI, contactUri)
+                    if (conversationUri != null)
+                        putString(ConversationPath.KEY_CONVERSATION_URI, conversationUri.uri)
+                })
+                putInt(
+                    TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
+                    if (hasVideo) VideoProfile.STATE_BIDIRECTIONAL
+                    else VideoProfile.STATE_AUDIO_ONLY)
+            }
+
+            // Build contact' Android URI
+            val callUri = android.net.Uri.parse(contactUri)
+            val key = "$accountId/$callUri"
+            val subject = SingleSubject.create<SystemCall>()
+
+            // Place call request
+            pendingCallRequests[key] = subject
+            try {
+                Log.w(TAG, "Telecom API: new outgoing call request for $callUri")
+                telecomService.placeCall(callUri, params)
+                return subject
+            } catch (e: SecurityException) {
+                pendingCallRequests.remove(key)
+                Log.e(TAG, "Can't use the Telecom API to place call", e)
+            }
+        }
+        // Fallback to allowing the call
+        return CALL_ALLOWED
+    }
+
+    fun onPlaceCallResult(uri: android.net.Uri, extras: Bundle, result: CallConnection?) {
+        val accountId = extras.getString(ConversationPath.KEY_ACCOUNT_ID) ?: return
+        Log.w(TAG, "Telecom API: outgoing call request for $uri has result $result")
+        pendingCallRequests.remove("$accountId/$uri")?.onSuccess(AndroidCall(result))
+    }
+
+    override fun requestIncomingCall(call: Call): Single<SystemCall> {
+        // Use the Android Telecom API if available
+        mContext.getSystemService<TelecomManager>()?.let { telecomService ->
+            val extras = Bundle()
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                if (call.hasActiveMedia(Media.MediaType.MEDIA_TYPE_VIDEO))
+                    extras.putInt(
+                        TelecomManager.EXTRA_INCOMING_VIDEO_STATE,
+                        VideoProfile.STATE_BIDIRECTIONAL
+                    )
+            }
+            extras.putString(ConversationPath.KEY_ACCOUNT_ID, call.account)
+            extras.putString(NotificationService.KEY_CALL_ID, call.daemonIdString)
+            extras.putString(ConversationPath.KEY_CONVERSATION_URI, call.contact?.uri?.rawUriString)
+
+            val key = call.daemonIdString!!
+            val subject = SingleSubject.create<SystemCall>()
+
+            // Place call request
+            incomingCallRequests[key] = Pair(call, subject)
+            try {
+                Log.w(TAG, "Telecom API: new incoming call request for $key")
+                telecomService.addNewIncomingCall(JamiApplication.instance!!.androidPhoneAccountHandle, extras)
+                return subject
+            } catch (e: SecurityException) {
+                incomingCallRequests.remove(key)
+                Log.e(TAG, "Can't use the Telecom API to place call", e)
+            }
+        }
+        // Fallback to allowing the call
+        return CALL_ALLOWED
+    }
+    fun onIncomingCallResult(extras: Bundle, connection: CallConnection?, result: CallRequestResult = CallRequestResult.REJECTED) {
+        val accountId = extras.getString(ConversationPath.KEY_ACCOUNT_ID) ?: return
+        val callId = extras.getString(NotificationService.KEY_CALL_ID) ?: return
+        Log.w(TAG, "Telecom API: incoming call request for $callId has result $connection")
+        incomingCallRequests.remove(callId)?.let {
+            it.second.onSuccess(if (connection != null && result != CallRequestResult.REJECTED) AndroidCall(connection).apply { setCall(it.first) } else SystemCall(
+                false
+            ))
+        }
+        if (connection == null || result == CallRequestResult.REJECTED)
+            refuse(accountId, callId)
+        else if (result == CallRequestResult.ACCEPTED || result == CallRequestResult.ACCEPTED_VIDEO)
+            accept(accountId, callId, result == CallRequestResult.ACCEPTED_VIDEO)
+    }
+
+}
\ No newline at end of file
diff --git a/jami-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt b/jami-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt
index c675600c0ad01c10a38deca56fa01df0955eb58b..660d09510ced6d973dcabded994242b5703141c0 100644
--- a/jami-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt
+++ b/jami-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt
@@ -19,20 +19,24 @@
  */
 package cx.ring.services
 
+import android.content.ContentProviderOperation
+import android.content.ContentResolver
 import android.content.ContentUris
 import android.content.Context
+import android.content.OperationApplicationException
 import android.database.Cursor
 import android.graphics.Bitmap
 import android.graphics.drawable.Drawable
 import android.net.Uri
+import android.os.RemoteException
 import android.provider.ContactsContract
 import android.util.Base64
 import android.util.Log
 import android.util.LongSparseArray
+import androidx.core.util.getOrElse
 import cx.ring.utils.AndroidFileUtils
 import cx.ring.views.AvatarFactory
 import ezvcard.VCard
-import io.reactivex.rxjava3.core.Maybe
 import io.reactivex.rxjava3.core.Single
 import io.reactivex.rxjava3.schedulers.Schedulers
 import net.jami.model.Contact
@@ -41,14 +45,13 @@ import net.jami.model.Phone
 import net.jami.model.Profile
 import net.jami.services.AccountService
 import net.jami.services.ContactService
-import net.jami.services.DeviceRuntimeService
 import net.jami.services.PreferencesService
 import net.jami.utils.VCardUtils
+import java.io.ByteArrayOutputStream
 
 class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesService,
-                         deviceRuntimeService : DeviceRuntimeService,
                          accountService: AccountService
-) : ContactService(preferenceService, deviceRuntimeService, accountService) {
+) : ContactService(preferenceService, accountService) {
     override fun loadContactsFromSystem(
         loadRingContacts: Boolean,
         loadSipContacts: Boolean
@@ -57,7 +60,7 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
         val contentResolver = mContext.contentResolver
         val contactsIds = StringBuilder()
         val cache: LongSparseArray<Contact>
-        var contactCursor = contentResolver.query(
+        val contactCursor = contentResolver.query(
             ContactsContract.Data.CONTENT_URI,
             CONTACTS_DATA_PROJECTION,
             ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=?",
@@ -86,13 +89,13 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
                 val contactType = contactCursor.getInt(indexType)
                 val contactLabel = contactCursor.getString(indexLabel)
                 val uri = net.jami.model.Uri.fromString(contactNumber)
-                var contact = cache[contactId]
                 var isNewContact = false
-                if (contact == null) {
-                    contact = Contact(uri)
-                    contact.setSystemId(contactId)
+                val contact = cache.getOrElse(contactId) {
                     isNewContact = true
-                    contact.isFromSystem = true
+                    Contact(uri).apply {
+                        setSystemId(contactId)
+                        isFromSystem = true
+                    }
                 }
                 if (uri.isSingleIp || uri.isHexId && loadRingContacts || loadSipContacts) {
                     when (contactCursor.getString(indexMime)) {
@@ -118,7 +121,6 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
                     }
                 }
                 if (isNewContact && contact.phones.isNotEmpty()) {
-                    cache.put(contactId, contact)
                     if (contactsIds.isNotEmpty()) {
                         contactsIds.append(",")
                     }
@@ -129,45 +131,43 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
         } else {
             cache = LongSparseArray()
         }
-        contactCursor = contentResolver.query(
+        contentResolver.query(
             ContactsContract.Contacts.CONTENT_URI, CONTACTS_SUMMARY_PROJECTION,
             ContactsContract.Contacts._ID + " in (" + contactsIds.toString() + ")", null,
             ContactsContract.Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC"
-        )
-        if (contactCursor != null) {
-            val indexId = contactCursor.getColumnIndex(ContactsContract.Contacts._ID)
-            val indexKey = contactCursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY)
-            val indexName = contactCursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
-            val indexPhoto = contactCursor.getColumnIndex(ContactsContract.Contacts.PHOTO_ID)
-            while (contactCursor.moveToNext()) {
-                val contactId = contactCursor.getLong(indexId)
+        )?.use {
+            val indexId = it.getColumnIndex(ContactsContract.Contacts._ID)
+            val indexKey = it.getColumnIndex(ContactsContract.Data.LOOKUP_KEY)
+            val indexName = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
+            val indexPhoto = it.getColumnIndex(ContactsContract.Contacts.PHOTO_ID)
+            while (it.moveToNext()) {
+                val contactId = it.getLong(indexId)
                 val contact = cache[contactId]
                 if (contact == null) Log.w(TAG, "Can't find contact with ID $contactId") else {
                     contact.setSystemContactInfo(
                         contactId,
-                        contactCursor.getString(indexKey),
-                        contactCursor.getString(indexName),
-                        contactCursor.getLong(indexPhoto)
+                        it.getString(indexKey),
+                        it.getString(indexName),
+                        it.getLong(indexPhoto)
                     )
                     systemContacts[contactId] = contact
                 }
             }
-            contactCursor.close()
         }
         return systemContacts
     }
 
-    override fun findContactByIdFromSystem(id: Long, key: String): Contact? {
+    override fun findContactByIdFromSystem(contactId: Long, contactKey: String?): Contact? {
         var contact: Contact? = null
         val contentResolver = mContext.contentResolver
         try {
-            val contentUri: Uri? = if (key != null) {
+            val contentUri: Uri? = if (contactKey != null) {
                 ContactsContract.Contacts.lookupContact(
                     contentResolver,
-                    ContactsContract.Contacts.getLookupUri(id, key)
+                    ContactsContract.Contacts.getLookupUri(contactId, contactKey)
                 )
             } else {
-                ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id)
+                ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId)
             }
             var result: Cursor? = null
             if (contentUri != null) {
@@ -198,10 +198,10 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
             }
             result.close()
         } catch (e: Exception) {
-            Log.d(TAG, "findContactByIdFromSystem: Error while searching for contact id=$id", e)
+            Log.d(TAG, "findContactByIdFromSystem: Error while searching for contact id=$contactId", e)
         }
         if (contact == null) {
-            Log.d(TAG, "findContactByIdFromSystem: findById $id can't find contact.")
+            Log.d(TAG, "findContactByIdFromSystem: findById $contactId can't find contact.")
         }
         return contact
     }
@@ -224,7 +224,7 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
                         cursorPhones.getString(indexLabel),
                         Phone.NumberType.TEL
                     )
-                    Log.d(TAG, "Phone:" + cursorPhones.getString(cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)))
+                    Log.d(TAG, "Phone:" + cursorPhones.getString(cursorPhones.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)))
                 }
                 cursorPhones.close()
             }
@@ -352,6 +352,83 @@ class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesSe
         return contact
     }
 
+    override fun saveContact(uri: String, profile: Profile) {
+        addOrUpdateContact(mContext.contentResolver, uri, profile.displayName ?: "", profile.avatar as Bitmap?)
+    }
+
+    override fun deleteContact(uri: String) {
+        deleteContact(mContext.contentResolver, uri)
+    }
+
+    private fun addOrUpdateContact(contentResolver: ContentResolver, phoneNumber: String, displayName: String, photo: Bitmap?) {
+        val operations = ArrayList<ContentProviderOperation>()
+
+        // Insert or update the RawContact
+        operations.add(
+            ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
+            .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
+            .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
+            .build())
+
+        // Insert or update the display name
+        operations.add(
+            ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
+            .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
+            .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
+            .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
+            .build())
+
+        // Insert or update the phone number
+        operations.add(
+            ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
+            .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
+            .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
+            .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, phoneNumber)
+            .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
+            .build())
+
+        // Insert or update the photo
+        photo?.let {
+            val stream = ByteArrayOutputStream()
+            it.compress(Bitmap.CompressFormat.JPEG, 87, stream)
+            val photoByteArray = stream.toByteArray()
+
+            operations.add(
+                ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
+                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
+                .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
+                .withValue(ContactsContract.CommonDataKinds.Photo.PHOTO, photoByteArray)
+                .build())
+        }
+
+        // Apply the batch of operations
+        try {
+            contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
+        } catch (e: RemoteException) {
+            e.printStackTrace()
+        } catch (e: OperationApplicationException) {
+            e.printStackTrace()
+        }
+    }
+
+    private fun deleteContact(contentResolver: ContentResolver, phoneNumber: String) {
+        val uri = ContactsContract.RawContacts.CONTENT_URI.buildUpon().build()
+        val selection = "${ContactsContract.CommonDataKinds.Phone.NUMBER} = ?"
+        val selectionArgs = arrayOf(phoneNumber)
+        contentResolver.query(
+            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+            arrayOf(ContactsContract.CommonDataKinds.Phone.CONTACT_ID),
+            selection,
+            selectionArgs,
+            null
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) {
+                val contactId = cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.CONTACT_ID))
+                contentResolver.delete(uri, "${ContactsContract.RawContacts.CONTACT_ID}=?", arrayOf(contactId.toString()))
+            }
+        }
+    }
+
     override fun loadContactData(contact: Contact, accountId: String): Single<Profile> {
         val profile: Single<Profile> =
             if (contact.isFromSystem) loadSystemContactData(contact)
diff --git a/jami-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt b/jami-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt
index 0d30ad53d0c2f987bb2bef7de768efa30a8ea1cc..171bb651b061e6d3e725deab03ecb4b8f98c4d92 100644
--- a/jami-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt
+++ b/jami-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt
@@ -20,6 +20,7 @@
  */
 package cx.ring.services
 
+import android.annotation.SuppressLint
 import android.bluetooth.BluetoothHeadset
 import android.content.Context
 import android.content.pm.PackageManager
@@ -29,15 +30,18 @@ import android.media.AudioManager.OnAudioFocusChangeListener
 import android.media.MediaRecorder
 import android.media.projection.MediaProjection
 import android.os.Build
+import android.telecom.CallAudioState
 import android.util.Log
 import android.util.Size
 import android.view.SurfaceHolder
 import android.view.SurfaceView
 import android.view.TextureView
 import android.view.WindowManager
+import androidx.annotation.RequiresApi
 import androidx.media.AudioAttributesCompat
 import androidx.media.AudioFocusRequestCompat
 import androidx.media.AudioManagerCompat
+import cx.ring.service.CallConnection
 import cx.ring.services.CameraService.CameraListener
 import cx.ring.utils.BluetoothWrapper
 import cx.ring.utils.BluetoothWrapper.BluetoothChangeListener
@@ -45,9 +49,11 @@ import io.reactivex.rxjava3.core.Completable
 import io.reactivex.rxjava3.core.Observable
 import io.reactivex.rxjava3.core.Scheduler
 import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.disposables.CompositeDisposable
 import net.jami.daemon.IntVect
 import net.jami.daemon.JamiService
 import net.jami.daemon.UintVect
+import net.jami.model.Call
 import net.jami.model.Call.CallStatus
 import net.jami.model.Conference
 import net.jami.services.HardwareService
@@ -112,13 +118,13 @@ class HardwareServiceImpl(
 
     private val RINGTONE_REQUEST = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT)
         .setAudioAttributes(AudioAttributesCompat.Builder()
-            .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
+            .setContentType(AudioAttributesCompat.CONTENT_TYPE_SONIFICATION)
             .setUsage(AudioAttributesCompat.USAGE_NOTIFICATION_RINGTONE)
             .setLegacyStreamType(AudioManager.STREAM_RING)
             .build())
         .setOnAudioFocusChangeListener(this)
         .build()
-    private val CALL_REQUEST = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT)
+    private val CALL_REQUEST = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
         .setAudioAttributes(AudioAttributesCompat.Builder()
             .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
             .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION)
@@ -138,35 +144,81 @@ class HardwareServiceImpl(
         }
     }
 
+    @SuppressLint("NewApi")
+    override fun getAudioState(conf: Conference): Observable<AudioState> =
+        conf.call!!.systemConnection
+            .flatMapObservable { a -> (a as CallServiceImpl.AndroidCall).call!!.audioState }
+            .map { a -> AudioState(routeToType(a.route), maskToList(a.supportedRouteMask)) }
+            .onErrorResumeWith { audioState }
+
+    private fun routeToType(a: Int): AudioOutput = when(a) {
+        CallAudioState.ROUTE_EARPIECE -> OUTPUT_INTERNAL
+        CallAudioState.ROUTE_WIRED_HEADSET -> OUTPUT_WIRED
+        CallAudioState.ROUTE_SPEAKER -> OUTPUT_SPEAKERS
+        CallAudioState.ROUTE_BLUETOOTH -> OUTPUT_BLUETOOTH
+        else -> OUTPUT_INTERNAL
+    }
+
+    private fun maskToList(routeMask: Int): List<AudioOutput> = ArrayList<AudioOutput>().apply {
+        if ((routeMask and CallAudioState.ROUTE_EARPIECE) != 0)
+            add(OUTPUT_INTERNAL)
+        if ((routeMask and CallAudioState.ROUTE_WIRED_HEADSET) != 0)
+            add(OUTPUT_WIRED)
+        if ((routeMask and CallAudioState.ROUTE_SPEAKER) != 0)
+            add(OUTPUT_SPEAKERS)
+        if ((routeMask and CallAudioState.ROUTE_BLUETOOTH) != 0)
+            add(OUTPUT_BLUETOOTH)
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    fun setAudioState(call: CallConnection, wantSpeaker: Boolean) {
+        Log.w(TAG, "setAudioState Telecom API $wantSpeaker ${call.callAudioState}")
+        call.setWantedAudioState(if (wantSpeaker) CallConnection.ROUTE_LIST_SPEAKER_IMPLICIT else CallConnection.ROUTE_LIST_DEFAULT)
+    }
+
+    val disposables = CompositeDisposable()
+
+    @SuppressLint("NewApi")
     @Synchronized
-    override fun updateAudioState(state: CallStatus, incomingCall: Boolean, isOngoingVideo: Boolean, isSpeakerOn: Boolean) {
-        Log.d(TAG, "updateAudioState: Call state updated to $state Call is incoming: $incomingCall Call is video: $isOngoingVideo")
-        val callEnded = state == CallStatus.HUNGUP || state == CallStatus.FAILURE || state == CallStatus.OVER
-        try {
-            if (mBluetoothWrapper == null && !callEnded) {
-                mBluetoothWrapper = BluetoothWrapper(context, this)
+    override fun updateAudioState(conf: Conference?, call: Call, incomingCall: Boolean, isOngoingVideo: Boolean) {
+        Log.d(TAG, "updateAudioState $conf: Call state updated to ${call.callStatus} Call is incoming: $incomingCall Call is video: $isOngoingVideo")
+        disposables.add(call.systemConnection.map {
+                (it as CallServiceImpl.AndroidCall).call!!
             }
-            when (state) {
-                CallStatus.RINGING -> {
-                    getFocus(RINGTONE_REQUEST)
-                    if (incomingCall) {
-                        // ringtone for incoming calls
-                        mAudioManager.mode = AudioManager.MODE_RINGTONE
-                        setAudioRouting(true)
-                    } else setAudioRouting(isOngoingVideo)
-                }
-                CallStatus.CURRENT -> {
-                    getFocus(CALL_REQUEST)
-                    mAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
-                    mShouldSpeakerphone = isOngoingVideo || isSpeakerOn
-                    setAudioRouting(mShouldSpeakerphone)
+            .subscribe({ systemCall ->
+                // Try using the Telecom API if available
+                setAudioState(systemCall, incomingCall || isOngoingVideo)
+            }) { e ->
+                Log.w(TAG, "updateAudioState fallback", e)
+                // Fallback on the AudioManager API
+                try {
+                    val state = call.callStatus
+                    val callEnded = state == CallStatus.HUNGUP || state == CallStatus.FAILURE || state == CallStatus.OVER
+                    if (mBluetoothWrapper == null && !callEnded) {
+                        mBluetoothWrapper = BluetoothWrapper(context, this)
+                    }
+                    when (state) {
+                        CallStatus.RINGING -> {
+                            getFocus(RINGTONE_REQUEST)
+                            if (incomingCall) {
+                                // ringtone for incoming calls
+                                mAudioManager.mode = AudioManager.MODE_RINGTONE
+                                setAudioRouting(true)
+                            } else setAudioRouting(isOngoingVideo)
+                        }
+                        CallStatus.CURRENT -> {
+                            getFocus(CALL_REQUEST)
+                            mAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
+                            mShouldSpeakerphone = isOngoingVideo || isSpeakerphoneOn()
+                            setAudioRouting(mShouldSpeakerphone)
+                        }
+                        CallStatus.HOLD, CallStatus.UNHOLD, CallStatus.INACTIVE -> {}
+                        else -> if (callEnded) closeAudioState()
+                    }
+                } catch (e: Exception) {
+                    Log.e(TAG, "Error updating audio state", e)
                 }
-                CallStatus.HOLD, CallStatus.UNHOLD, CallStatus.INACTIVE -> {}
-                else -> if (callEnded) closeAudioState()
-            }
-        } catch (e: Exception) {
-            Log.e(TAG, "Error updating audio state", e)
-        }
+            })
     }
 
     /*
@@ -243,7 +295,7 @@ class HardwareServiceImpl(
         mAudioManager.isSpeakerphoneOn = false
         mBluetoothWrapper!!.setBluetoothOn(true)
         mAudioManager.mode = oldMode
-        audioStateSubject.onNext(AudioState(AudioOutput.BLUETOOTH))
+        audioStateSubject.onNext(AudioState(OUTPUT_BLUETOOTH))
     }
 
     /**
@@ -258,7 +310,7 @@ class HardwareServiceImpl(
             mAudioManager.mode = oldMode
         }
         mAudioManager.isSpeakerphoneOn = true
-        audioStateSubject.onNext(STATE_SPEAKERS)
+        audioStateSubject.onNext(AudioState(OUTPUT_SPEAKERS))
     }
 
     /**
@@ -270,18 +322,33 @@ class HardwareServiceImpl(
         audioStateSubject.onNext(STATE_INTERNAL)
     }
 
+    @SuppressLint("NewApi")
     @Synchronized
-    override fun toggleSpeakerphone(checked: Boolean) {
-        JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin())
-        mShouldSpeakerphone = checked
-
-        if (mHasSpeakerPhone && checked) {
-            routeToSpeaker()
-        } else if (mBluetoothWrapper != null && mBluetoothWrapper!!.canBluetooth()) {
-            routeToBTHeadset()
-        } else {
-            resetAudio()
-        }
+    override fun toggleSpeakerphone(conf: Conference, checked: Boolean) {
+        Log.w(TAG, "toggleSpeakerphone $conf $checked")
+        val hasVideo = conf.hasActiveVideo()
+        disposables.add(conf.call!!.systemConnection
+            .map {
+                // Map before subscribe to fallback to the error path if no Telecom API
+                (it as CallServiceImpl.AndroidCall).call!!
+            }
+            .subscribe({
+                // Using the Telecom API
+                it.setWantedAudioState(if (checked) CallConnection.ROUTE_LIST_SPEAKER_EXPLICIT
+                else if (hasVideo) CallConnection.ROUTE_LIST_SPEAKER_IMPLICIT
+                else CallConnection.ROUTE_LIST_DEFAULT)
+            }) {
+                // Fallback to the AudioManager API
+                JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin())
+                mShouldSpeakerphone = checked
+                if (mHasSpeakerPhone && checked) {
+                    routeToSpeaker()
+                } else if (mBluetoothWrapper != null && mBluetoothWrapper!!.canBluetooth()) {
+                    routeToBTHeadset()
+                } else {
+                    resetAudio()
+                }
+            })
     }
 
     @Synchronized
diff --git a/jami-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt b/jami-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt
index 0222190b169228da14e5fb6e1da1c3b7f76fca96..02a3f3a2c591368d6f48d90c810c2f5a4d9e5bc1 100644
--- a/jami-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt
+++ b/jami-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt
@@ -83,13 +83,15 @@ class NotificationServiceImpl(
     private val mAccountService: AccountService,
     private val mContactService: ContactService,
     private val mPreferencesService: PreferencesService,
-    private val mDeviceRuntimeService: DeviceRuntimeService
-) : NotificationService {
+    private val mDeviceRuntimeService: DeviceRuntimeService,
+    private val mCallService: CallService
+    ) : NotificationService {
     private val mNotificationBuilders = SparseArray<NotificationCompat.Builder>()
     private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(mContext)
     private val random = Random()
     private val avatarSize = (mContext.resources.displayMetrics.density * AvatarFactory.SIZE_NOTIF).toInt()
     private val currentCalls = LinkedHashMap<String, Conference>()
+
     private val callNotifications = ConcurrentHashMap<Int, Notification>()
     private val dataTransferNotifications = ConcurrentHashMap<Int, Notification>()
     private var pendingNotificationActions = ArrayList<() -> Unit>()
@@ -109,7 +111,7 @@ class NotificationServiceImpl(
         mContext.startActivity(Intent(Intent.ACTION_VIEW)
                 .putExtra(NotificationService.KEY_CALL_ID, callId)
                 .setClass(mContext.applicationContext, TVCallActivity::class.java)
-                .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK))
+                .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION))
     }
 
     private fun buildCallNotification(conference: Conference): Notification? {
@@ -149,7 +151,7 @@ class NotificationServiceImpl(
             if (conference.isIncoming) {
                 messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_INCOMING_CALL)
                 messageNotificationBuilder.setContentTitle(mContext.getString(R.string.notif_incoming_call_title, contact.displayName))
-                    .setPriority(NotificationCompat.PRIORITY_MAX)
+                    .setPriority(NotificationCompat.PRIORITY_HIGH)
                     .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
                     .setContentText(mContext.getText(R.string.notif_incoming_call))
                     .setContentIntent(viewIntent)
@@ -264,7 +266,10 @@ class NotificationServiceImpl(
      * @param notificationId the notification's id
      */
     private fun updateNotification(notification: Notification, notificationId: Int) {
-        notificationManager.notify(notificationId, notification)
+        try {
+            notificationManager.notify(notificationId, notification)
+        } catch (e: SecurityException) {
+        }
     }
 
     /**
@@ -274,6 +279,22 @@ class NotificationServiceImpl(
      * @param remove     true if it should be removed from current calls
      */
     override fun handleCallNotification(conference: Conference, remove: Boolean) {
+        if (!remove && conference.isIncoming && conference.state == Call.CallStatus.RINGING) {
+            // Filter case where state is ringing but we haven't receive the media list yet
+            val call = conference.call ?: return
+            if (call.mediaList == null)
+                return
+            mCallService.requestIncomingCall(call).subscribe { result ->
+                Log.w(TAG, "Telecom API: requestIncomingCall result ${result.allowed}")
+                if (result.allowed) {
+                    manageCallNotification(conference, remove)
+                }
+            }
+        } else {
+            manageCallNotification(conference, remove)
+        }
+    }
+    private fun manageCallNotification(conference: Conference, remove: Boolean) {
         if (DeviceUtils.isTv(mContext)) {
             if (!remove) startCallActivity(conference.id)
             return
@@ -294,6 +315,7 @@ class NotificationServiceImpl(
         }
 
         // Send notification to the  Service
+        Log.w(TAG, "showCallNotification $notification")
         if (notification != null) {
             val nid = random.nextInt()
             callNotifications[nid] = notification
diff --git a/jami-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt b/jami-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt
index 55cc5c66e87e219cd7ceaafe90aa3eebd130cf69..353b5122b368321443cc31605954422063a637f9 100644
--- a/jami-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt
+++ b/jami-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt
@@ -54,14 +54,12 @@ import cx.ring.mvp.BaseSupportFragment
 import cx.ring.service.DRingService
 import cx.ring.tv.main.HomeActivity
 import cx.ring.utils.ActionHelper
-import cx.ring.utils.ContentUriHandler
 import cx.ring.utils.ConversationPath
 import cx.ring.views.AvatarDrawable
 import dagger.hilt.android.AndroidEntryPoint
 import net.jami.call.CallPresenter
 import net.jami.call.CallView
 import net.jami.daemon.JamiService
-import net.jami.model.Call
 import net.jami.model.Call.CallStatus
 import net.jami.model.Conference.ParticipantInfo
 import net.jami.model.Contact
@@ -108,7 +106,7 @@ class TVCallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView
     override fun initPresenter(presenter: CallPresenter) {
         val args = requireArguments()
         args.getString(CallFragment.KEY_ACTION)?.let { action ->
-            if (action == CallFragment.ACTION_PLACE_CALL || action == Intent.ACTION_CALL)
+            if (action == Intent.ACTION_CALL)
                 prepareCall(false)
             else if (action == Intent.ACTION_VIEW || action == DRingService.ACTION_CALL_ACCEPT)
                 presenter.initIncomingCall(args.getString(NotificationService.KEY_CALL_ID)!!, action == Intent.ACTION_VIEW)
@@ -391,7 +389,7 @@ class TVCallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView
         }
         if (!hasVideo) {
             val videoGranted = mDeviceRuntimeService.hasVideoPermission()
-            if ((!audioGranted || !videoGranted) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            if (!audioGranted || !videoGranted) {
                 val perms = ArrayList<String>()
                 if (!videoGranted) {
                     perms.add(Manifest.permission.CAMERA)
@@ -400,13 +398,13 @@ class TVCallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView
                     perms.add(Manifest.permission.RECORD_AUDIO)
                 }
                 requestPermissions(perms.toTypedArray(), permissionType)
-            } else if (audioGranted && videoGranted) {
+            } else {
                 initializeCall(isIncoming)
             }
         } else {
-            if (!audioGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            if (!audioGranted) {
                 requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), permissionType)
-            } else if (audioGranted) {
+            } else {
                 initializeCall(isIncoming)
             }
         }
diff --git a/jami-android/app/src/main/java/cx/ring/utils/BluetoothWrapper.kt b/jami-android/app/src/main/java/cx/ring/utils/BluetoothWrapper.kt
index 8aec5d5c27c9f309961bd6d9eedc2d39e13e74e2..81265352cc12e6d9796f8cea65338b913bf52418 100644
--- a/jami-android/app/src/main/java/cx/ring/utils/BluetoothWrapper.kt
+++ b/jami-android/app/src/main/java/cx/ring/utils/BluetoothWrapper.kt
@@ -80,20 +80,24 @@ class BluetoothWrapper(private val mContext: Context, private val btChangesListe
             if (AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED == action) {
                 val status = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR)
                 Log.d(TAG, "BT SCO state changed : $status target is $targetBt")
-                if (status == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
-                    Log.d(TAG, "BT SCO state changed : CONNECTED")
-                    audioManager.isBluetoothScoOn = true
-                    isBluetoothConnecting = false
-                    isBluetoothConnected = true
-                    btChangesListener.onBluetoothStateChanged(BluetoothHeadset.STATE_AUDIO_CONNECTED)
-                } else if (status == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) {
-                    Log.d(TAG, "BT SCO state changed : DISCONNECTED")
-                    audioManager.isBluetoothScoOn = false
-                    isBluetoothConnecting = false
-                    isBluetoothConnected = false
-                    btChangesListener.onBluetoothStateChanged(BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
-                } else {
-                    Log.d(TAG, "BT SCO state changed : $status")
+                when (status) {
+                    AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
+                        Log.d(TAG, "BT SCO state changed : CONNECTED")
+                        audioManager.isBluetoothScoOn = true
+                        isBluetoothConnecting = false
+                        isBluetoothConnected = true
+                        btChangesListener.onBluetoothStateChanged(BluetoothHeadset.STATE_AUDIO_CONNECTED)
+                    }
+                    AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> {
+                        Log.d(TAG, "BT SCO state changed : DISCONNECTED")
+                        audioManager.isBluetoothScoOn = false
+                        isBluetoothConnecting = false
+                        isBluetoothConnected = false
+                        btChangesListener.onBluetoothStateChanged(BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
+                    }
+                    else -> {
+                        Log.d(TAG, "BT SCO state changed : $status")
+                    }
                 }
             }
         }
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/call/CallPresenter.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/call/CallPresenter.kt
index ce76871da04d14034b7e29015bd6f4a816b708b9..dfe6c7c48226f8985ed1dadd061c837af17a90c4 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/call/CallPresenter.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/call/CallPresenter.kt
@@ -96,20 +96,16 @@ class CallPresenter @Inject constructor(
             .subscribe { state: AudioState -> this.view?.updateAudioState(state) })
     }
 
-    fun initOutGoing(accountId: String?, conversationUri: Uri?, contactUri: String?, hasVideo: Boolean) {
+    fun initOutGoing(accountId: String, conversationUri: Uri?, contactUri: String?, hasVideo: Boolean) {
         Log.e(TAG, "initOutGoing")
-        var pHasVideo = hasVideo
-        if (accountId == null || contactUri == null) {
+        if (accountId.isEmpty() || contactUri == null) {
             Log.e(TAG, "initOutGoing: null account or contact")
             hangupCall()
             return
         }
-        if (!mHardwareService.hasCamera()) {
-            pHasVideo = false
-        }
-        //getView().blockScreenRotation();
+        val pHasVideo = hasVideo && mHardwareService.hasCamera()
         val callObservable = mCallService
-            .placeCall(accountId, conversationUri, fromString(toNumber(contactUri)!!), pHasVideo)
+            .placeCallIfAllowed(accountId, conversationUri, fromString(toNumber(contactUri)!!), pHasVideo)
             .flatMapObservable { call: Call -> mCallService.getConfUpdates(call) }
             .share()
         mCompositeDisposable.add(callObservable
@@ -237,7 +233,8 @@ class CallPresenter @Inject constructor(
     }
 
     fun speakerClick(checked: Boolean) {
-        mHardwareService.toggleSpeakerphone(checked)
+        val conference = mConference ?: return
+        mHardwareService.toggleSpeakerphone(conference, checked)
     }
 
     /**
@@ -676,10 +673,7 @@ class CallPresenter @Inject constructor(
 
     fun holdCurrentCall() {
         mCallService.currentConferences().filter { it != mConference }.forEach { conf ->
-            if (conf.isSimpleCall)
-                mCallService.hold(conf.accountId, conf.id)
-            else
-                mCallService.holdConference(conf.accountId, conf.id)
+            mCallService.holdCallOrConference(conf)
         }
     }
 
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Call.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Call.kt
index 5354424845e56411839c639111a8f4a3894365a4..3cbe60c852076d8b60e465eedb6eb2a56f4d23c3 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Call.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Call.kt
@@ -21,11 +21,13 @@ package net.jami.model
 
 import ezvcard.Ezvcard
 import ezvcard.VCard
-import net.jami.call.CallPresenter
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.subjects.SingleSubject
+import net.jami.services.CallService
 import net.jami.utils.Log
 import net.jami.utils.ProfileChunk
-import net.jami.utils.StringUtils
 import net.jami.utils.VCardUtils
+import java.lang.UnsupportedOperationException
 import java.util.*
 
 class Call : Interaction {
@@ -75,6 +77,17 @@ class Call : Interaction {
 
     private var mProfileChunk: ProfileChunk? = null
 
+    private val systemConnectionSubject: SingleSubject<CallService.SystemCall> = SingleSubject.create()
+    fun setSystemConnection(value: CallService.SystemCall?) {
+        Log.w(TAG, "Telecom API: setSystemConnection $value")
+        if (value != null)
+            systemConnectionSubject.onSuccess(value);
+        else
+            systemConnectionSubject.onError(UnsupportedOperationException())
+    }
+    val systemConnection: Single<CallService.SystemCall>
+        get() = systemConnectionSubject
+
     constructor(
         daemonId: String?,
         author: String?,
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conference.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conference.kt
index f237e9a221e4ff11ee2cc4df6055563f4b83b960..77bf12c371c081bf505419951b3b6d7146bfc09d 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conference.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conference.kt
@@ -23,7 +23,6 @@ package net.jami.model
 import io.reactivex.rxjava3.core.Observable
 import io.reactivex.rxjava3.subjects.BehaviorSubject
 import io.reactivex.rxjava3.subjects.Subject
-import net.jami.call.CallPresenter
 import net.jami.model.Call.CallStatus
 import net.jami.model.Call.CallStatus.Companion.fromConferenceString
 import java.util.*
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt
index 9abb80515876552b3f693b8b3860ea3c99557129..1ad6dfc3cb77546f47219bb7a6866cb0309dbc19 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt
@@ -543,7 +543,7 @@ class Conversation : ConversationHistory {
         val previous = mMessages.put(id, interaction)
         val action = if (previous == null) ElementStatus.ADD else ElementStatus.UPDATE
         if (previous != null && interaction.type != Interaction.InteractionType.INVALID) {
-            // We update a reaction, but the views might be subscribed to the old model
+            // We update an interaction, but the views might be subscribed to the old model
             // Migrate the observables to the new model
             interaction.updateFrom(previous)
         }
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/CallService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/CallService.kt
index 141f392d0fcab8a9fbc5e5a861abcf3207dabb8c..06d244418b96f62a6084832c62d3cf77f6f1515b 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/CallService.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/CallService.kt
@@ -35,10 +35,11 @@ import net.jami.utils.Log
 import java.util.*
 import java.util.concurrent.ScheduledExecutorService
 
-class CallService(
+abstract class CallService(
     private val mExecutor: ScheduledExecutorService,
-    private val mContactService: ContactService,
-    private val mAccountService: AccountService
+    val mContactService: ContactService,
+    val mAccountService: AccountService,
+    val mDeviceRuntimeService: DeviceRuntimeService
 ) {
     private val calls: MutableMap<String, Call> = HashMap()
     private val conferences: MutableMap<String, Conference> = HashMap()
@@ -152,7 +153,7 @@ class CallService(
         }
     }
 
-    private class ConferenceEntity constructor(var conference: Conference)
+    private class ConferenceEntity(var conference: Conference)
 
     fun getConfUpdates(call: Call): Observable<Conference> = getConfUpdates(getConference(call))
 
@@ -186,12 +187,39 @@ class CallService(
             .startWithItem(call)
             .takeWhile { c: Call -> c.callStatus !== CallStatus.OVER }
 
-    fun placeCallObservable(accountId: String, conversationUri: Uri?, number: Uri, hasVideo: Boolean): Observable<Call> {
-        return placeCall(accountId, conversationUri, number, hasVideo)
-            .flatMapObservable { call: Call -> getCallUpdates(call) }
+
+    /** Use a system API, if available, to request to start a call. */
+    open class SystemCall(val allowed: Boolean) {
+        open fun setCall(call: Call) {
+            call.setSystemConnection(null)
+        }
     }
+    open fun requestPlaceCall(
+        accountId: String,
+        conversationUri: Uri?,
+        contactUri: String,
+        hasVideo: Boolean
+    ): Single<SystemCall> = CALL_ALLOWED
+
+    open fun requestIncomingCall(
+        call: Call
+    ): Single<SystemCall> = CALL_ALLOWED
 
-    fun placeCall(account: String, conversationUri: Uri?, number: Uri, hasVideo: Boolean): Single<Call> =
+    fun placeCallObservable(accountId: String, conversationUri: Uri?, number: Uri, hasVideo: Boolean): Observable<Call> =
+        placeCall(accountId, conversationUri, number, hasVideo)
+            .flatMapObservable { call: Call -> getCallUpdates(call) }
+
+    fun placeCallIfAllowed(account: String, conversationUri: Uri?, number: Uri, hasVideo: Boolean): Single<Call> =
+        requestPlaceCall(account, conversationUri, number.rawUriString, hasVideo)
+            .flatMap { result ->
+                if (!result.allowed)
+                    Single.error(SecurityException())
+                else
+                    placeCall(account, conversationUri, number, hasVideo)
+                        .doOnSuccess { result.setCall(it) }
+            }
+
+    private fun placeCall(account: String, conversationUri: Uri?, number: Uri, hasVideo: Boolean): Single<Call> =
         Single.fromCallable<Call> {
             Log.i(TAG, "placeCall() thread running... $number hasVideo: $hasVideo")
             val media = VectMap()
@@ -259,6 +287,19 @@ class CallService(
         }
     }
 
+    fun holdCallOrConference(conf: Conference) {
+        if (conf.isSimpleCall)
+            hold(conf.accountId, conf.id)
+        else
+            holdConference(conf.accountId, conf.id)
+    }
+    fun unholdCallOrConference(conf: Conference) {
+        if (conf.isSimpleCall)
+            unhold(conf.accountId, conf.id)
+        else
+            unholdConference(conf.accountId, conf.id)
+    }
+
     fun hold(accountId:String, callId: String) {
         mExecutor.execute {
             Log.i(TAG, "hold() running... $callId")
@@ -407,7 +448,7 @@ class CallService(
                 val contact = mContactService.findContact(account, from)
                 val conversationUri = contact.conversationUri.blockingFirst()
                 val conversation =
-                    if (conversationUri.equals(from)) account.getByUri(from) else account.getSwarm(conversationUri.rawRingId)
+                    if (conversationUri == from) account.getByUri(from) else account.getSwarm(conversationUri.rawRingId)
                 Call(callId, from.uri, accountId, conversation, contact, direction)
             }.apply { mediaList = media }
         }
@@ -489,7 +530,8 @@ class CallService(
         val call = calls[callId]
         if (call != null) {
             call.isAudioMuted = muted
-            callSubject.onNext(call)
+            if (call.callStatus == CallStatus.CURRENT)
+                callSubject.onNext(call)
         } else {
             conferences[callId]?.let { conf ->
                 conf.isAudioMuted = muted
@@ -501,7 +543,8 @@ class CallService(
     fun videoMuted(callId: String, muted: Boolean) {
         calls[callId]?.let { call ->
             call.isVideoMuted = muted
-            callSubject.onNext(call)
+            if (call.callStatus == CallStatus.CURRENT)
+                callSubject.onNext(call)
         }
         conferences[callId]?.let { conf ->
             conf.isVideoMuted = muted
@@ -700,10 +743,14 @@ class CallService(
     }
 
     companion object {
-        private val TAG = CallService::class.simpleName!!
+        @JvmStatic
+        protected val TAG = CallService::class.simpleName!!
         const val MIME_TEXT_PLAIN = "text/plain"
         const val MIME_GEOLOCATION = "application/geo"
         const val MEDIA_TYPE_AUDIO = "MEDIA_TYPE_AUDIO"
         const val MEDIA_TYPE_VIDEO = "MEDIA_TYPE_VIDEO"
+
+        val CALL_ALLOWED = Single.just(SystemCall(true))
+        val CALL_DISALLOWED = Single.just(SystemCall(false))
     }
 }
\ No newline at end of file
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ContactService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ContactService.kt
index e729742cea77ae60aa9923999e00326f653aaa07..751fd710c14c16b2b8edf6f63cde328ec5829573 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ContactService.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ContactService.kt
@@ -21,6 +21,7 @@
 package net.jami.services
 
 import ezvcard.VCard
+import io.reactivex.rxjava3.core.Completable
 import io.reactivex.rxjava3.core.Observable
 import io.reactivex.rxjava3.core.ObservableEmitter
 import io.reactivex.rxjava3.core.Single
@@ -37,15 +38,19 @@ import java.util.concurrent.TimeUnit
  * - Provide query tools to search contacts by id, number, ...
  */
 abstract class ContactService(
-    val mPreferencesService: PreferencesService,
-    val mDeviceRuntimeService: DeviceRuntimeService,
-    val mAccountService: AccountService
+    protected val mPreferencesService: PreferencesService,
+    protected val mAccountService: AccountService
 ) {
     abstract fun loadContactsFromSystem(loadRingContacts: Boolean, loadSipContacts: Boolean): Map<Long, Contact>
-    protected abstract fun findContactByIdFromSystem(contactId: Long, contactKey: String): Contact?
+    protected abstract fun findContactByIdFromSystem(contactId: Long, contactKey: String?): Contact?
     protected abstract fun findContactBySipNumberFromSystem(number: String): Contact?
     protected abstract fun findContactByNumberFromSystem(number: String): Contact?
     abstract fun loadContactData(contact: Contact, accountId: String): Single<Profile>
+
+    abstract fun saveContact(uri: String, profile: Profile)
+    abstract fun deleteContact(uri: String)
+
+
     abstract fun saveVCardContactData(contact: Contact, accountId: String, vcard: VCard)
     abstract fun saveVCardContact(accountId: String, uri: String?, displayName: String?, pictureB64: String?): Single<VCard>
 
@@ -55,15 +60,13 @@ abstract class ContactService(
      * @param loadRingContacts if true, ring contacts will be taken care of
      * @param loadSipContacts  if true, sip contacts will be taken care of
      */
-    fun loadContacts(loadRingContacts: Boolean, loadSipContacts: Boolean, account: Account?): Single<Map<Long, Contact>> {
-        return Single.fromCallable {
+    fun loadContacts(loadRingContacts: Boolean, loadSipContacts: Boolean, account: Account?): Single<Map<Long, Contact>> =
+        Single.fromCallable {
             val settings = mPreferencesService.settings
-            if (settings.useSystemContacts && mDeviceRuntimeService.hasContactPermission()) {
-                return@fromCallable loadContactsFromSystem(loadRingContacts, loadSipContacts)
-            }
-            HashMap()
+            if (settings.useSystemContacts) {
+                loadContactsFromSystem(loadRingContacts, loadSipContacts)
+            } else HashMap()
         }
-    }
 
     fun observeContact(accountId: String, contactUri: Uri, withPresence: Boolean): Observable<ContactViewModel> {
         val account = mAccountService.getAccount(accountId) ?: return Observable.error(IllegalArgumentException())
@@ -139,6 +142,10 @@ abstract class ContactService(
             }
         }
 
+    fun getLoadedContact(accountId: String, contactId: String, withPresence: Boolean = false): Single<ContactViewModel> =
+        mAccountService.getAccountSingle(accountId)
+            .flatMap { getLoadedContact(accountId, it.getContactFromCache(contactId), withPresence) }
+
     fun getLoadedContact(accountId: String, contact: Contact, withPresence: Boolean = false): Single<ContactViewModel> =
         observeContact(accountId, contact, withPresence)
             .firstOrError()
@@ -148,6 +155,10 @@ abstract class ContactService(
             .concatMapEager { contact: Contact -> getLoadedContact(accountId, contact, withPresence).toObservable() }
             .toList(contacts.size)
 
+    fun getLoadedConversation(accountId: String, conversationUri: Uri): Single<ConversationItemViewModel> =
+        mAccountService.getAccountSingle(accountId)
+            .flatMap { getLoadedConversation(it.getByUri(conversationUri)!!) }
+
     fun getLoadedConversation(conversation: Conversation): Single<ConversationItemViewModel> =
         conversation.contactUpdates.firstOrError().flatMap { contacts -> Single.zip(getLoadedContact(
             conversation.accountId, contacts, false),
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ConversationFacade.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ConversationFacade.kt
index 2f96c078ddf9e445fe65a5f93c23f60be34f4c7a..722a0db97aa221053d337862ffb2e11a635c4476 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ConversationFacade.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/ConversationFacade.kt
@@ -556,14 +556,12 @@ class ConversationFacade(
     }
 
     private fun onCallStateChange(call: Call) {
-        Log.d(TAG, "onCallStateChange Thread id: " + Thread.currentThread().id)
         val newState = call.callStatus
         val incomingCall = newState === CallStatus.RINGING && call.isIncoming
-        mHardwareService.updateAudioState(newState, incomingCall, call.hasMedia(Media.MediaType.MEDIA_TYPE_VIDEO), mHardwareService.isSpeakerphoneOn())
         val account = mAccountService.getAccount(call.account!!) ?: return
         val contact = call.contact
         val conversationId = call.conversationId
-        Log.w(TAG, "CallStateChange ${call.daemonIdString} conversationId:$conversationId contact:$contact ${contact?.conversationUri?.blockingFirst()}")
+        Log.w(TAG, "CallStateChange ${call.daemonIdString}->$newState conversationId:$conversationId contact:$contact ${contact?.conversationUri?.blockingFirst()}")
         val conversation = if (conversationId == null)
             if (contact == null)
                 null
@@ -576,6 +574,7 @@ class ConversationFacade(
             conversation.addConference(this)
             account.updated(conversation)
         }) else null
+        mHardwareService.updateAudioState(conference, call, incomingCall, call.hasMedia(Media.MediaType.MEDIA_TYPE_VIDEO))
 
         Log.w(TAG, "CALL_STATE_CHANGED : updating call state to $newState")
         if ((newState.isRinging || newState === CallStatus.CURRENT) && call.timestamp == 0L) {
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/DeviceRuntimeService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/DeviceRuntimeService.kt
index 31caa6705710e1e2d1402940d25fc6103ed699a1..bc0cf56fb8d149db2a26c706649eff067cecd932 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/DeviceRuntimeService.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/DeviceRuntimeService.kt
@@ -62,4 +62,5 @@ abstract class DeviceRuntimeService : SystemInfoCallbacks {
     abstract fun hasGalleryPermission(): Boolean
     abstract val profileName: String?
     abstract fun hardLinkOrCopy(source: File, dest: File): Boolean
-}
\ No newline at end of file
+
+}
diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HardwareService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HardwareService.kt
index 45e132acad8dbe481654d3e795a3d294c2052e60..c47937590381bad724d97f2c733c3bf544d2a904 100644
--- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HardwareService.kt
+++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HardwareService.kt
@@ -31,6 +31,7 @@ import net.jami.daemon.IntVect
 import net.jami.daemon.UintVect
 import net.jami.daemon.JamiService
 import net.jami.daemon.StringMap
+import net.jami.model.Call
 import net.jami.model.Conference
 import net.jami.utils.Log
 import java.util.concurrent.ScheduledExecutorService
@@ -53,11 +54,12 @@ abstract class HardwareService(
 
     data class BluetoothEvent(val connected: Boolean)
 
-    enum class AudioOutput {
-        INTERNAL, SPEAKERS, BLUETOOTH
+    enum class AudioOutputType {
+        INTERNAL, WIRED, SPEAKERS, BLUETOOTH
     }
+    data class AudioOutput(val type: AudioOutputType, val outputName: String? = null, val outputId: String? = null)
 
-    data class AudioState(val outputType: AudioOutput, val outputName: String? = null)
+    data class AudioState(val output: AudioOutput, val availableOutputs: List<AudioOutput> = emptyList())
 
     protected val videoEvents: Subject<VideoEvent> = PublishSubject.create()
     protected val cameraEvents: Subject<VideoEvent> = PublishSubject.create()
@@ -70,6 +72,7 @@ abstract class HardwareService(
 
     fun getBluetoothEvents(): Observable<BluetoothEvent> = bluetoothEvents
 
+    abstract fun getAudioState(conf: Conference): Observable<AudioState>
     val audioState: Observable<AudioState>
         get() = audioStateSubject
     val connectivityState: Observable<Boolean>
@@ -77,11 +80,11 @@ abstract class HardwareService(
 
     abstract fun initVideo(): Completable
     abstract val isVideoAvailable: Boolean
-    abstract fun updateAudioState(state: CallStatus, incomingCall: Boolean, isOngoingVideo: Boolean, isSpeakerOn: Boolean)
+    abstract fun updateAudioState(conf: Conference?, call: Call, incomingCall: Boolean, isOngoingVideo: Boolean)
     abstract fun closeAudioState()
     abstract fun isSpeakerphoneOn(): Boolean
 
-    abstract fun toggleSpeakerphone(checked: Boolean)
+    abstract fun toggleSpeakerphone(conf: Conference, checked: Boolean)
     abstract fun abandonAudioFocus()
     abstract fun decodingStarted(id: String, shmPath: String, width: Int, height: Int, isMixer: Boolean)
     abstract fun decodingStopped(id: String, shmPath: String, isMixer: Boolean)
@@ -199,7 +202,10 @@ abstract class HardwareService(
 
     companion object {
         private val TAG = HardwareService::class.simpleName!!
-        val STATE_SPEAKERS = AudioState(AudioOutput.SPEAKERS)
-        val STATE_INTERNAL = AudioState(AudioOutput.INTERNAL)
+        val OUTPUT_SPEAKERS = AudioOutput(AudioOutputType.SPEAKERS)
+        val OUTPUT_INTERNAL = AudioOutput(AudioOutputType.INTERNAL)
+        val OUTPUT_WIRED = AudioOutput(AudioOutputType.WIRED)
+        val OUTPUT_BLUETOOTH = AudioOutput(AudioOutputType.BLUETOOTH)
+        val STATE_INTERNAL = AudioState(OUTPUT_INTERNAL)
     }
 }
\ No newline at end of file