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