From b551408d6e97dac11689ba700aef0d742971b9be Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Thu, 30 Apr 2026 10:12:51 +0200 Subject: [PATCH 1/4] iOS AVS: wire call manager and flow manager for video paths --- .../logic/feature/call/AppleAvsInterop.kt | 425 ++++++++++++ .../logic/feature/call/CallManagerImpl.kt | 633 +++++++++++++++++- .../feature/call/FlowManagerServiceImpl.kt | 36 +- .../logic/feature/call/GlobalCallManager.kt | 62 +- .../wire/kalium/logic/util/PlatformView.kt | 6 +- 5 files changed, 1089 insertions(+), 73 deletions(-) create mode 100644 logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt new file mode 100644 index 000000000000..8c22c77605a2 --- /dev/null +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt @@ -0,0 +1,425 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * 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 http://www.gnu.org/licenses/. + */ + +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.wire.kalium.logic.feature.call + +import avs.wcall_answer +import avs.wcall_config_update +import avs.wcall_create +import avs.wcall_end +import avs.wcall_network_changed +import avs.wcall_process_notifications +import avs.wcall_recv_msg +import avs.wcall_reject +import avs.wcall_request_video_streams +import avs.wcall_resp +import avs.wcall_run +import avs.wcall_set_active_speaker_handler +import avs.wcall_set_background +import avs.wcall_set_clients_for_conv +import avs.wcall_set_epoch_info +import avs.wcall_set_group_changed_handler +import avs.wcall_set_mute +import avs.wcall_set_mute_handler +import avs.wcall_set_network_quality_handler +import avs.wcall_set_participant_changed_handler +import avs.wcall_set_req_clients_handler +import avs.wcall_set_req_new_epoch_handler +import avs.wcall_set_video_send_state +import avs.wcall_sft_resp +import avs.wcall_setup +import avs.wcall_start +import com.wire.kalium.common.logger.kaliumLogger +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned + +@Suppress("TooManyFunctions") +internal object AppleAvsInterop { + interface Callbacks { + fun onReady(version: Int) + fun onSend( + context: COpaquePointer?, + conversationId: String?, + selfUserId: String?, + selfClientId: String?, + targetRecipientsJson: String?, + clientIdDestination: String?, + data: ByteArray, + transient: Boolean, + myClientsOnly: Boolean + ): Int + fun onSftRequest(context: COpaquePointer?, url: String?, data: ByteArray): Int + fun onIncomingCall(conversationId: String?, messageTime: UInt, userId: String?, clientId: String?, video: Boolean, shouldRing: Boolean, conversationType: Int) + fun onMissedCall(conversationId: String?, messageTime: UInt, userId: String?, video: Boolean) + fun onAnsweredCall(conversationId: String?) + fun onEstablishedCall(conversationId: String?, userId: String?, clientId: String?) + fun onClosedCall(reason: Int, conversationId: String?, messageTime: UInt, userId: String?, clientId: String?) + fun onMetrics(conversationId: String?, metricsJson: String?) + fun onConfigRequest(handle: UInt, context: COpaquePointer?): Int + fun onAudioCbrChanged(userId: String?, clientId: String?, enabled: Boolean) + fun onVideoStateChanged(conversationId: String?, userId: String?, clientId: String?, state: Int) + fun onParticipantChanged(conversationId: String?, data: String?) + fun onNetworkQualityChanged(conversationId: String?, userId: String?, clientId: String?, qualityInfoJson: String?) + fun onRequestNewEpoch(handle: UInt, conversationId: String?) + fun onClientsRequest(handle: UInt, conversationId: String?) + fun onActiveSpeakersChanged(handle: UInt, conversationId: String?, data: String?) + fun onMuteStateChanged(isMuted: Boolean) + } + + data class CreatedHandle(val handle: UInt, val stableRef: StableRef) + + private var isStarted = false + private val handles = mutableMapOf() + + private fun callbacks(arg: COpaquePointer?): Callbacks? = arg?.asStableRef()?.get() + private fun CPointer?.string(): String? = this?.toKString() + private fun CPointer?.bytes(length: ULong): ByteArray = this?.readBytes(length.toInt()) ?: byteArrayOf() + + private val readyHandler = staticCFunction { version: Int, arg: COpaquePointer? -> + callbacks(arg)?.onReady(version) + Unit + } + + private val sendHandler = staticCFunction { + context: COpaquePointer?, + conversationId: CPointer?, + userId: CPointer?, + clientId: CPointer?, + targetRecipientsJson: CPointer?, + clientIdDestination: CPointer?, + data: CPointer?, + length: ULong, + transient: Int, + myClientsOnly: Int, + arg: COpaquePointer? -> + callbacks(arg)?.onSend( + context = context, + conversationId = conversationId.string(), + selfUserId = userId.string(), + selfClientId = clientId.string(), + targetRecipientsJson = targetRecipientsJson.string(), + clientIdDestination = clientIdDestination.string(), + data = data.bytes(length), + transient = transient != 0, + myClientsOnly = myClientsOnly != 0 + ) ?: AvsCallBackError.INVALID_ARGUMENT.value + } + + private val sftRequestHandler = staticCFunction { + context: COpaquePointer?, + url: CPointer?, + data: CPointer?, + length: ULong, + arg: COpaquePointer? -> + callbacks(arg)?.onSftRequest(context, url.string(), data.bytes(length)) ?: AvsCallBackError.INVALID_ARGUMENT.value + } + + private val incomingHandler = staticCFunction { + conversationId: CPointer?, + msgTime: UInt, + userId: CPointer?, + clientId: CPointer?, + video: Int, + shouldRing: Int, + conversationType: Int, + arg: COpaquePointer? -> + callbacks(arg)?.onIncomingCall(conversationId.string(), msgTime, userId.string(), clientId.string(), video != 0, shouldRing != 0, conversationType) + Unit + } + + private val missedHandler = staticCFunction { + conversationId: CPointer?, + msgTime: UInt, + userId: CPointer?, + _: CPointer?, + video: Int, + arg: COpaquePointer? -> + callbacks(arg)?.onMissedCall(conversationId.string(), msgTime, userId.string(), video != 0) + Unit + } + + private val answeredHandler = staticCFunction { conversationId: CPointer?, arg: COpaquePointer? -> + callbacks(arg)?.onAnsweredCall(conversationId.string()) + Unit + } + + private val establishedHandler = staticCFunction { + conversationId: CPointer?, + userId: CPointer?, + clientId: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onEstablishedCall(conversationId.string(), userId.string(), clientId.string()) + Unit + } + + private val closeHandler = staticCFunction { + reason: Int, + conversationId: CPointer?, + msgTime: UInt, + userId: CPointer?, + clientId: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onClosedCall(reason, conversationId.string(), msgTime, userId.string(), clientId.string()) + Unit + } + + private val metricsHandler = staticCFunction { + conversationId: CPointer?, + metricsJson: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onMetrics(conversationId.string(), metricsJson.string()) + Unit + } + + private val configRequestHandler = staticCFunction { handle: UInt, arg: COpaquePointer? -> + callbacks(arg)?.onConfigRequest(handle, arg) ?: AvsCallBackError.INVALID_ARGUMENT.value + } + + private val audioCbrHandler = staticCFunction { + userId: CPointer?, + clientId: CPointer?, + enabled: Int, + arg: COpaquePointer? -> + callbacks(arg)?.onAudioCbrChanged(userId.string(), clientId.string(), enabled != 0) + Unit + } + + private val videoStateHandler = staticCFunction { + conversationId: CPointer?, + userId: CPointer?, + clientId: CPointer?, + state: Int, + arg: COpaquePointer? -> + callbacks(arg)?.onVideoStateChanged(conversationId.string(), userId.string(), clientId.string(), state) + Unit + } + + private val participantChangedHandler = staticCFunction { + conversationId: CPointer?, + data: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onParticipantChanged(conversationId.string(), data.string()) + Unit + } + + private val networkQualityHandler = staticCFunction { + conversationId: CPointer?, + userId: CPointer?, + clientId: CPointer?, + qualityInfoJson: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onNetworkQualityChanged(conversationId.string(), userId.string(), clientId.string(), qualityInfoJson.string()) + Unit + } + + private val requestNewEpochHandler = staticCFunction { handle: UInt, conversationId: CPointer?, arg: COpaquePointer? -> + callbacks(arg)?.onRequestNewEpoch(handle, conversationId.string()) + Unit + } + + private val clientsRequestHandler = staticCFunction { handle: UInt, conversationId: CPointer?, arg: COpaquePointer? -> + callbacks(arg)?.onClientsRequest(handle, conversationId.string()) + Unit + } + + private val activeSpeakerHandler = staticCFunction { + handle: UInt, + conversationId: CPointer?, + data: CPointer?, + arg: COpaquePointer? -> + callbacks(arg)?.onActiveSpeakersChanged(handle, conversationId.string(), data.string()) + Unit + } + + private val muteHandler = staticCFunction { isMuted: Int, arg: COpaquePointer? -> + callbacks(arg)?.onMuteStateChanged(isMuted != 0) + Unit + } + + fun startIfAvailable(): Boolean { + if (isStarted) return true + + return runCatching { + val setupResult = wcall_setup() + val runResult = wcall_run() + isStarted = true + kaliumLogger.i("AVS iOS smoke: started AVS via cinterop (wcall_setup=$setupResult, wcall_run=$runResult)") + true + }.getOrElse { error -> + kaliumLogger.w("AVS iOS smoke: failed to start AVS via cinterop (${error.message ?: error::class.simpleName})") + false + } + } + + fun userHandle(selfUserId: String, selfClientId: String, callbacks: Callbacks): UInt? { + if (!startIfAvailable()) return null + + val key = "$selfUserId:$selfClientId" + handles[key]?.let { return it.handle } + + return runCatching { + val stableRef = StableRef.create(callbacks) + val arg = stableRef.asCPointer() + val handle = wcall_create( + userid = selfUserId, + clientid = selfClientId, + readyh = readyHandler, + sendh = sendHandler, + sfth = sftRequestHandler, + incomingh = incomingHandler, + missedh = missedHandler, + answerh = answeredHandler, + estabh = establishedHandler, + closeh = closeHandler, + metricsh = metricsHandler, + cfg_reqh = configRequestHandler, + acbrh = audioCbrHandler, + vstateh = videoStateHandler, + arg = arg + ) + registerAdditionalHandlers(handle, arg) + handles[key] = CreatedHandle(handle, stableRef) + kaliumLogger.i("AVS iOS smoke: created wcall user handle=$handle for user=$selfUserId client=$selfClientId") + handle + }.getOrElse { error -> + kaliumLogger.w("AVS iOS smoke: failed to create wcall user (${error.message ?: error::class.simpleName})") + null + } + } + + private fun registerAdditionalHandlers(handle: UInt, arg: COpaquePointer) { + wcall_set_participant_changed_handler(handle, participantChangedHandler, arg) + wcall_set_network_quality_handler(handle, networkQualityHandler, DEFAULT_NETWORK_QUALITY_INTERVAL_SECONDS, arg) + wcall_set_req_new_epoch_handler(handle, requestNewEpochHandler) + wcall_set_req_clients_handler(handle, clientsRequestHandler) + wcall_set_active_speaker_handler(handle, activeSpeakerHandler) + wcall_set_mute_handler(handle, muteHandler, arg) + wcall_set_group_changed_handler(handle, null, arg) + } + + fun receiveCallingMessage( + handle: UInt, + payload: ByteArray, + currentTimeSeconds: UInt, + messageTimeSeconds: UInt, + conversationId: String, + senderUserId: String, + senderClientId: String, + conversationType: Int + ): Boolean { + if (payload.isEmpty()) return false + + return runCatching { + val result = payload.usePinned { pinned -> + wcall_recv_msg( + wuser = handle, + buf = pinned.addressOf(0).reinterpret(), + len = payload.size.toULong(), + curr_time = currentTimeSeconds, + msg_time = messageTimeSeconds, + convid = conversationId, + userid = senderUserId, + clientid = senderClientId, + conv_type = conversationType, + meeting = 0 + ) + } + kaliumLogger.i("AVS iOS smoke: wcall_recv_msg result=$result conversation=$conversationId") + true + }.getOrElse { error -> + kaliumLogger.w("AVS iOS smoke: wcall_recv_msg failed (${error.message ?: error::class.simpleName})") + false + } + } + + fun respondToSend(handle: UInt, status: Int, reason: String, context: COpaquePointer?) { + wcall_resp(handle, status, reason, context) + } + + fun respondToSft(handle: UInt, error: Int, data: ByteArray, context: COpaquePointer?) { + data.usePinned { pinned -> + wcall_sft_resp(handle, error, pinned.addressOf(0).reinterpret(), data.size.toULong(), context) + } + } + + fun updateConfig(handle: UInt, error: Int, json: String) { + wcall_config_update(handle, error, json) + } + + fun startCall(handle: UInt, conversationId: String, callType: Int, conversationType: Int, audioCbr: Boolean): Int = + wcall_start(handle, conversationId, callType, conversationType, audioCbr.toAvsInt(), 0) + + fun answerCall(handle: UInt, conversationId: String, callType: Int, audioCbr: Boolean): Int = + wcall_answer(handle, conversationId, callType, audioCbr.toAvsInt()) + + fun endCall(handle: UInt, conversationId: String) = wcall_end(handle, conversationId) + + fun rejectCall(handle: UInt, conversationId: String): Int = wcall_reject(handle, conversationId) + + fun setMute(handle: UInt, muted: Boolean) = wcall_set_mute(handle, muted.toAvsInt()) + + fun setVideoSendState(handle: UInt, conversationId: String, state: Int) = wcall_set_video_send_state(handle, conversationId, state) + + fun requestVideoStreams(handle: UInt, conversationId: String, mode: Int, json: String): Int = + wcall_request_video_streams(handle, conversationId, mode, json) + + fun setEpochInfo(handle: UInt, conversationId: String, epoch: UInt, clientsJson: String, keyBase64: String): Int = + wcall_set_epoch_info(handle, conversationId, epoch, clientsJson, keyBase64) + + fun setClientsForConversation(handle: UInt, conversationId: String, clients: String): Int = + wcall_set_clients_for_conv(handle, conversationId, clients) + + fun processNotifications(handle: UInt, isStarted: Boolean): Int = + wcall_process_notifications(handle, isStarted.toAvsInt()) + + fun setBackground(handle: UInt, background: Boolean): Int = wcall_set_background(handle, background.toAvsInt()) + + fun setNetworkQualityInterval(handle: UInt, callbacks: Callbacks, intervalInSeconds: Int) { + val arg = handles.values.firstOrNull { it.stableRef.get() === callbacks }?.stableRef?.asCPointer() + wcall_set_network_quality_handler(handle, networkQualityHandler, intervalInSeconds, arg) + } + + fun notifyNetworkChangedIfAvailable(): Boolean { + if (!startIfAvailable()) return false + + return runCatching { + wcall_network_changed() + kaliumLogger.i("AVS iOS smoke: networkChanged propagated to AVS via cinterop") + true + }.getOrElse { error -> + kaliumLogger.w("AVS iOS smoke: failed to propagate networkChanged (${error.message ?: error::class.simpleName})") + false + } + } + + private fun Boolean.toAvsInt() = if (this) 1 else 0 + + private const val DEFAULT_NETWORK_QUALITY_INTERVAL_SECONDS = 1 +} diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt index fb997173e5b2..256e13481b2b 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt @@ -16,24 +16,172 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("konsist.useCasesShouldNotAccessNetworkLayerDirectly") + package com.wire.kalium.logic.feature.call +import com.wire.kalium.calling.CallClosedReason +import com.wire.kalium.calling.CallTypeCalling import com.wire.kalium.calling.ConversationTypeCalling +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.fold +import com.wire.kalium.common.functional.foldToEitherWhileRight +import com.wire.kalium.common.functional.nullableFold +import com.wire.kalium.common.logger.callingLogger +import com.wire.kalium.logger.obfuscateId +import com.wire.kalium.logic.cache.SelfConversationIdProvider +import com.wire.kalium.logic.configuration.UserConfigRepository +import com.wire.kalium.logic.data.call.CallActiveSpeakers +import com.wire.kalium.logic.data.call.CallClient import com.wire.kalium.logic.data.call.CallClientList +import com.wire.kalium.logic.data.call.CallHelperImpl +import com.wire.kalium.logic.data.call.CallMetadata +import com.wire.kalium.logic.data.call.CallParticipants +import com.wire.kalium.logic.data.call.CallRepository +import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.CallType +import com.wire.kalium.logic.data.call.ConversationTypeForCall import com.wire.kalium.logic.data.call.EpochInfo import com.wire.kalium.logic.data.call.Participant import com.wire.kalium.logic.data.call.TestVideoType import com.wire.kalium.logic.data.call.VideoState +import com.wire.kalium.logic.data.call.VideoStateChecker +import com.wire.kalium.logic.data.call.mapper.CallMapper +import com.wire.kalium.logic.data.call.mapper.ParticipantMapperImpl +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.CurrentClientIdProvider +import com.wire.kalium.logic.data.id.FederatedIdMapper +import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.message.Message import com.wire.kalium.logic.data.message.MessageContent -import com.wire.kalium.common.logger.kaliumLogger +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.call.usecase.ConversationClientsInCallUpdater +import com.wire.kalium.logic.feature.call.usecase.CreateAndPersistRecentlyEndedCallMetadataUseCase +import com.wire.kalium.logic.feature.call.usecase.EpochInfoUpdater +import com.wire.kalium.logic.feature.call.usecase.GetCallConversationTypeProvider +import com.wire.kalium.logic.featureFlags.KaliumConfigs +import com.wire.kalium.logic.util.ServerTimeHandler +import com.wire.kalium.logic.util.ServerTimeHandlerImpl +import com.wire.kalium.messaging.sending.MessageSender +import com.wire.kalium.messaging.sending.MessageTarget +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import io.ktor.util.encodeBase64 +import kotlinx.cinterop.COpaquePointer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid + +@Suppress("LongParameterList", "TooManyFunctions") +internal class CallManagerImpl internal constructor( + private val callRepository: CallRepository, + private val currentClientIdProvider: CurrentClientIdProvider, + private val selfConversationIdProvider: SelfConversationIdProvider, + private val messageSender: MessageSender, + private val callMapper: CallMapper, + private val federatedIdMapper: FederatedIdMapper, + private val qualifiedIdMapper: QualifiedIdMapper, + videoStateChecker: VideoStateChecker, + private val conversationClientsInCallUpdater: ConversationClientsInCallUpdater, + private val epochInfoUpdater: EpochInfoUpdater, + private val networkStateObserver: NetworkStateObserver, + private val getCallConversationType: GetCallConversationTypeProvider, + private val userConfigRepository: UserConfigRepository, + private val kaliumConfigs: KaliumConfigs, + private val mediaManagerService: MediaManagerService, + private val flowManagerService: FlowManagerService, + private val createAndPersistRecentlyEndedCallMetadata: CreateAndPersistRecentlyEndedCallMetadataUseCase, + private val selfUserId: UserId, + private val serverTimeHandler: ServerTimeHandler = ServerTimeHandlerImpl(), + kaliumDispatchers: KaliumDispatcher = KaliumDispatcherImpl +) : CallManager { + private val tagWithUserId = "$TAG(${selfUserId.toLogString()})" + private val job = SupervisorJob() + private val scope = CoroutineScope(job + kaliumDispatchers.io) + private val participantMapper = ParticipantMapperImpl(videoStateChecker, callMapper, qualifiedIdMapper) + private val callHelper = CallHelperImpl(userConfigRepository, callRepository) + private val callbacks = AppleCallbacks() + private val deferredHandle: Deferred = startHandleAsync() + + private val clientId: Deferred = scope.async(start = CoroutineStart.LAZY) { + currentClientIdProvider().fold({ failure -> + error("Cannot initialize iOS calling without self client id: $failure") + }, { + callingLogger.d("$tagWithUserId: clientId: $it") + it + }) + } + + private val initializeServerTimeOffsetJob: Deferred = scope.async(start = CoroutineStart.LAZY) { + callRepository.fetchServerTime()?.let { serverTime -> + callingLogger.d("$tagWithUserId: Computing server time offset: $serverTime") + serverTimeHandler.computeTimeOffset(Instant.parse(serverTime).epochSeconds) + } ?: callingLogger.w("$tagWithUserId: Failed to fetch server time for offset computation.") + } + + private suspend fun ensureServerTimeOffsetComputed() { + if (!initializeServerTimeOffsetJob.isCompleted && !initializeServerTimeOffsetJob.isCancelled) { + initializeServerTimeOffsetJob.await() + } + } -@Suppress("TooManyFunctions") -internal class CallManagerImpl : CallManager { - override suspend fun onCallingMessageReceived(message: Message.Signaling, content: MessageContent.Calling) { - kaliumLogger.w("Ignoring call message since calling is not supported") + private fun startHandleAsync(): Deferred = scope.async(start = CoroutineStart.LAZY) { + launch { + callingLogger.i("$tagWithUserId: Starting MediaManager") + mediaManagerService.startMediaManager() + }.join() + launch { + callingLogger.i("$tagWithUserId: Starting FlowManager") + flowManagerService.startFlowManager() + }.join() + callingLogger.i("$tagWithUserId: Creating iOS AVS Handle") + AppleAvsInterop.userHandle( + selfUserId = federatedIdMapper.parseToFederatedId(selfUserId), + selfClientId = clientId.await().value, + callbacks = callbacks + ) ?: error("Failed to create iOS AVS wcall user") + } + + private suspend fun withCalling(action: suspend (handle: UInt) -> T): T = action(deferredHandle.await()) + + override suspend fun onCallingMessageReceived(message: Message.Signaling, content: MessageContent.Calling) = withCalling { handle -> + ensureServerTimeOffsetComputed() + callingLogger.i("$tagWithUserId: onCallingMessageReceived called: { \"message\" : ${message.toLogString()}}") + val targetConversationId = if (message.isSelfMessage) { + content.conversationId ?: message.conversationId + } else { + message.conversationId + } + val callConversationType = getCallConversationType(targetConversationId) + val type = callMapper.toConversationType(callConversationType) + val received = AppleAvsInterop.receiveCallingMessage( + handle = handle, + payload = content.value.encodeToByteArray(), + currentTimeSeconds = serverTimeHandler.toServerTimestamp().toUInt(), + messageTimeSeconds = message.date.epochSeconds.toUInt(), + conversationId = federatedIdMapper.parseToFederatedId(targetConversationId), + senderUserId = federatedIdMapper.parseToFederatedId(message.senderUserId), + senderClientId = message.senderClientId.value, + conversationType = callMapper.toConversationTypeCalling(type).avsValue + ) + callingLogger.i("$tagWithUserId: wcall_recv_msg() forwarded=$received") } override suspend fun startCall( @@ -42,66 +190,489 @@ internal class CallManagerImpl : CallManager { conversationTypeCalling: ConversationTypeCalling, isAudioCbr: Boolean ) { - kaliumLogger.w("Calls not supported on iOS: startCall ignored") + callingLogger.d("$tagWithUserId: starting call for conversationId: ${conversationId.toLogString()}..") + val isCameraOn = callType == CallType.VIDEO + val type = callMapper.toConversationType(conversationTypeCalling) + + callRepository.createCall( + conversationId = conversationId, + type = type, + status = CallStatus.STARTED, + isMuted = false, + isCameraOn = isCameraOn, + isCbrEnabled = isAudioCbr, + callerId = selfUserId + ) + + withCalling { handle -> + val avsCallType = callMapper.toCallTypeCalling(callType) + val startAvs = suspend { + AppleAvsInterop.startCall( + handle = handle, + conversationId = federatedIdMapper.parseToFederatedId(conversationId), + callType = avsCallType.avsValue, + conversationType = conversationTypeCalling.avsValue, + audioCbr = isAudioCbr + ) + callingLogger.d("$tagWithUserId: wcall_start() called -> ${conversationId.toLogString()}") + } + + if (callRepository.getCallMetadata(conversationId)?.protocol is Conversation.ProtocolInfo.MLS) { + callRepository.joinMlsConference( + conversationId = conversationId, + onJoined = startAvs, + onEpochChange = { conversation, epochInfo -> updateEpochInfo(conversation, epochInfo) } + ) + } else { + startAvs() + } + } } - override suspend fun answerCall(conversationId: ConversationId, isAudioCbr: Boolean, isVideoCall: Boolean) { - kaliumLogger.w("Calls not supported on iOS: answerCall ignored") + override suspend fun answerCall(conversationId: ConversationId, isAudioCbr: Boolean, isVideoCall: Boolean) = withCalling { handle -> + callingLogger.d("$tagWithUserId: answering call for conversationId: ${conversationId.toLogString()}..") + val callType = if (isVideoCall) CallTypeCalling.VIDEO else CallTypeCalling.AUDIO + val answerAvs = suspend { + AppleAvsInterop.answerCall( + handle = handle, + conversationId = federatedIdMapper.parseToFederatedId(conversationId), + callType = callType.avsValue, + audioCbr = isAudioCbr + ) + callingLogger.i("$tagWithUserId: wcall_answer() called -> ${conversationId.toLogString()}") + } + + if (callRepository.getCallMetadata(conversationId)?.protocol is Conversation.ProtocolInfo.MLS) { + callRepository.joinMlsConference( + conversationId = conversationId, + onJoined = answerAvs, + onEpochChange = { conversation, epochInfo -> updateEpochInfo(conversation, epochInfo) } + ) + } else { + answerAvs() + } + Unit } - override suspend fun endCall(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: endCall ignored") + override suspend fun endCall(conversationId: ConversationId) = withCalling { handle -> + callingLogger.d("$tagWithUserId: endCall -> ${conversationId.toLogString()}") + AppleAvsInterop.endCall(handle, federatedIdMapper.parseToFederatedId(conversationId)) + Unit } - override suspend fun rejectCall(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: rejectCall ignored") + override suspend fun rejectCall(conversationId: ConversationId) = withCalling { handle -> + callingLogger.d("$tagWithUserId: rejectCall -> ${conversationId.toLogString()}") + AppleAvsInterop.rejectCall(handle, federatedIdMapper.parseToFederatedId(conversationId)) + Unit } - override suspend fun muteCall(shouldMute: Boolean) { - kaliumLogger.w("Calls not supported on iOS: muteCall ignored") + override suspend fun muteCall(shouldMute: Boolean) = withCalling { handle -> + AppleAvsInterop.setMute(handle, shouldMute) + callingLogger.d("$tagWithUserId: wcall_set_mute() called") + Unit } - override suspend fun setVideoSendState(conversationId: ConversationId, videoState: VideoState) { - kaliumLogger.w("Calls not supported on iOS: setVideoSendState ignored") + override suspend fun setVideoSendState(conversationId: ConversationId, videoState: VideoState) = withCalling { handle -> + val videoStateCalling = callMapper.toVideoStateCalling(videoState) + AppleAvsInterop.setVideoSendState(handle, federatedIdMapper.parseToFederatedId(conversationId), videoStateCalling.avsValue) + Unit } - override suspend fun requestVideoStreams(conversationId: ConversationId, callClients: CallClientList) { - kaliumLogger.w("Calls not supported on iOS: requestVideoStreams ignored") + override suspend fun requestVideoStreams(conversationId: ConversationId, callClients: CallClientList) = withCalling { handle -> + val clients = callClients.clients.map { callClient -> + CallClient( + userId = federatedIdMapper.parseToFederatedId(callClient.userId), + clientId = callClient.clientId, + isMemberOfSubconversation = callClient.isMemberOfSubconversation, + quality = callClient.quality + ) + } + AppleAvsInterop.requestVideoStreams( + handle = handle, + conversationId = federatedIdMapper.parseToFederatedId(conversationId), + mode = DEFAULT_REQUEST_VIDEO_STREAMS_MODE, + json = CallClientList(clients).toJsonString() + ) + Unit } - override suspend fun updateEpochInfo(conversationId: ConversationId, epochInfo: EpochInfo) { - kaliumLogger.w("Calls not supported on iOS: updateEpochInfo ignored") + override suspend fun updateEpochInfo(conversationId: ConversationId, epochInfo: EpochInfo) = withCalling { handle -> + callingLogger.d("$tagWithUserId: wcall_set_epoch_info() called -> ${conversationId.toLogString()} epoch=${epochInfo.epoch}") + AppleAvsInterop.setEpochInfo( + handle = handle, + conversationId = federatedIdMapper.parseToFederatedId(conversationId), + epoch = epochInfo.epoch.toUInt(), + clientsJson = epochInfo.members.toJsonString(), + keyBase64 = epochInfo.sharedSecret.encodeBase64() + ) + Unit } override suspend fun updateConversationClients(conversationId: ConversationId, clients: String) { - kaliumLogger.w("Calls not supported on iOS: updateConversationClients ignored") + if (callRepository.getCallMetadata(conversationId)?.protocol is Conversation.ProtocolInfo.Proteus) { + withCalling { handle -> + AppleAvsInterop.setClientsForConversation(handle, federatedIdMapper.parseToFederatedId(conversationId), clients) + } + } } - override suspend fun reportProcessNotifications(isStarted: Boolean) { - kaliumLogger.w("Calls not supported on iOS: reportProcessNotifications ignored") + override suspend fun reportProcessNotifications(isStarted: Boolean) = withCalling { handle -> + AppleAvsInterop.processNotifications(handle, isStarted) + Unit } override suspend fun setTestVideoType(testVideoType: TestVideoType) { - kaliumLogger.w("Calls not supported on iOS: setTestVideoType ignored") + callingLogger.w("Calls partially supported on iOS: setTestVideoType ignored") } override suspend fun setTestPreviewActive(shouldEnable: Boolean) { - kaliumLogger.w("Calls not supported on iOS: setTestPreviewActive ignored") + callingLogger.w("Calls partially supported on iOS: setTestPreviewActive ignored") } override suspend fun setTestRemoteVideoStates(conversationId: ConversationId, participants: List) { - kaliumLogger.w("Calls not supported on iOS: setTestRemoteVideoStates ignored") + callingLogger.w("Calls partially supported on iOS: setTestRemoteVideoStates ignored") } - override suspend fun setBackground(background: Boolean) { - kaliumLogger.w("Calls not supported on iOS: setBackground ignored") + override suspend fun setBackground(background: Boolean) = withCalling { handle -> + AppleAvsInterop.setBackground(handle, background) + Unit } - override suspend fun setNetworkQualityInterval(intervalInSeconds: Int) { - kaliumLogger.w("Calls not supported on iOS: setNetworkQualityInterval ignored") + override suspend fun setNetworkQualityInterval(intervalInSeconds: Int) = withCalling { handle -> + AppleAvsInterop.setNetworkQualityInterval(handle, callbacks, intervalInSeconds) + Unit } override suspend fun cancelJobs() { - kaliumLogger.w("Calls not supported on iOS: cancelJobs ignored") + deferredHandle.cancel() + scope.cancel() + job.cancel() + } + + private inner class AppleCallbacks : AppleAvsInterop.Callbacks { + override fun onReady(version: Int) { + callingLogger.i("$tagWithUserId: readyHandler; version=$version") + onCallingReady() + } + + override fun onSend( + context: COpaquePointer?, + conversationId: String?, + selfUserId: String?, + selfClientId: String?, + targetRecipientsJson: String?, + clientIdDestination: String?, + data: ByteArray, + transient: Boolean, + myClientsOnly: Boolean + ): Int { + callingLogger.i("[OnSendOTR/iOS] -> ConversationId: ${conversationId?.obfuscateId()}") + if (conversationId == null || selfUserId == null || selfClientId == null) return AvsCallBackError.INVALID_ARGUMENT.value + return try { + val messageTarget = if (myClientsOnly) { + CallingMessageTarget.Self + } else { + val specificTarget = targetRecipientsJson?.let { recipientsJson -> + callMapper.toClientMessageTarget(Json.decodeFromString(recipientsJson)) + } ?: MessageTarget.Conversation() + CallingMessageTarget.HostConversation(specificTarget) + } + enqueueCallingMessage( + context = context, + callHostConversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationId), + messageString = data.decodeToString(), + avsSelfUserId = qualifiedIdMapper.fromStringToQualifiedID(selfUserId), + avsSelfClientId = ClientId(selfClientId), + messageTarget = messageTarget + ) + AvsCallBackError.NONE.value + } catch (e: Exception) { + callingLogger.e("[OnSendOTR/iOS] -> Error Exception: $e") + AvsCallBackError.COULD_NOT_DECODE_ARGUMENT.value + } + } + + override fun onSftRequest(context: COpaquePointer?, url: String?, data: ByteArray): Int { + if (url == null) return AvsCallBackError.INVALID_ARGUMENT.value + scope.launch { + val connected = withTimeoutOrNull(DEFAULT_WAIT_UNTIL_CONNECTED_TIMEOUT) { + networkStateObserver.observeNetworkState().firstOrNull { it is NetworkState.ConnectedWithInternet } + } != null + if (!connected) { + AppleAvsInterop.respondToSft(deferredHandle.await(), AvsSFTError.NO_RESPONSE_DATA.value, byteArrayOf(), context) + return@launch + } + val dataString = data.decodeToString() + val responseData = callRepository.connectToSFT(url = url, data = dataString).nullableFold({ null }, { it }) ?: byteArrayOf() + val error = if (responseData.isEmpty()) AvsSFTError.NO_RESPONSE_DATA.value else AvsSFTError.NONE.value + AppleAvsInterop.respondToSft(deferredHandle.await(), error, responseData, context) + } + return AvsCallBackError.NONE.value + } + + override fun onIncomingCall( + conversationId: String?, + messageTime: UInt, + userId: String?, + clientId: String?, + video: Boolean, + shouldRing: Boolean, + conversationType: Int + ) { + if (conversationId == null || userId == null) return + callingLogger.i( + "[OnIncomingCall/iOS] -> ConversationId: ${conversationId.obfuscateId()}" + + " | UserId: ${userId.obfuscateId()} | shouldRing: $shouldRing | type: $conversationType" + ) + val mappedConversationType = callMapper.fromIntToConversationType(conversationType) + val isMuted = setOf(ConversationTypeForCall.Conference, ConversationTypeForCall.ConferenceMls).contains(mappedConversationType) + val status = if (shouldRing) CallStatus.INCOMING else CallStatus.STILL_ONGOING + scope.launch { + callRepository.createCall( + conversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationId), + status = status, + callerId = qualifiedIdMapper.fromStringToQualifiedID(userId), + isMuted = isMuted, + isCameraOn = video, + type = mappedConversationType, + isCbrEnabled = kaliumConfigs.forceConstantBitrateCalls + ) + } + } + + override fun onMissedCall(conversationId: String?, messageTime: UInt, userId: String?, video: Boolean) { + callingLogger.i("[OnMissedCall/iOS] - conversationId: ${conversationId?.obfuscateId()} | userId: ${userId?.obfuscateId()}") + } + + override fun onAnsweredCall(conversationId: String?) { + if (conversationId == null) return + scope.launch { + callRepository.updateCallStatusById(qualifiedIdMapper.fromStringToQualifiedID(conversationId), CallStatus.ANSWERED) + } + } + + override fun onEstablishedCall(conversationId: String?, userId: String?, clientId: String?) { + if (conversationId == null) return + scope.launch { + callRepository.updateCallStatusById(qualifiedIdMapper.fromStringToQualifiedID(conversationId), CallStatus.ESTABLISHED) + } + } + + override fun onClosedCall(reason: Int, conversationId: String?, messageTime: UInt, userId: String?, clientId: String?) { + if (conversationId == null) return + handleClosedCall(reason, conversationId) + } + + override fun onMetrics(conversationId: String?, metricsJson: String?) { + callingLogger.i("$tagWithUserId: Calling metrics: $metricsJson") + } + + override fun onConfigRequest(handle: UInt, context: COpaquePointer?): Int { + scope.launch { + callRepository.getCallConfigResponse(limit = null).fold({ + callingLogger.w("[OnConfigRequest/iOS] - Error: $it") + AppleAvsInterop.updateConfig(handle, 1, "") + }, { config -> + AppleAvsInterop.updateConfig(handle, 0, kaliumConfigs.callConfigTransformer?.invoke(config) ?: config) + }) + } + return AvsCallBackError.NONE.value + } + + override fun onAudioCbrChanged(userId: String?, clientId: String?, enabled: Boolean) { + scope.launch { callRepository.updateIsCbrEnabled(enabled) } + } + + override fun onVideoStateChanged(conversationId: String?, userId: String?, clientId: String?, state: Int) { + callingLogger.i( + "[onVideoReceiveStateChanged/iOS] - conversationId: ${conversationId?.obfuscateId()}" + + " | userId: ${userId?.obfuscateId()} clientId: ${clientId?.obfuscateId()} | state: $state" + ) + } + + override fun onParticipantChanged(conversationId: String?, data: String?) { + if (conversationId == null || data == null) return + scope.launch { handleParticipantsChanged(conversationId, data) } + } + + override fun onNetworkQualityChanged(conversationId: String?, userId: String?, clientId: String?, qualityInfoJson: String?) { + if (conversationId == null || qualityInfoJson == null) return + val callQualityData = Json.decodeFromString(qualityInfoJson) + callRepository.updateCallQualityData(qualifiedIdMapper.fromStringToQualifiedID(conversationId), callQualityData) + } + + override fun onRequestNewEpoch(handle: UInt, conversationId: String?) { + if (conversationId == null) return + scope.launch { epochInfoUpdater(qualifiedIdMapper.fromStringToQualifiedID(conversationId)) } + } + + override fun onClientsRequest(handle: UInt, conversationId: String?) { + if (conversationId == null) return + scope.launch { + val conversationIdWithDomain = qualifiedIdMapper.fromStringToQualifiedID(conversationId) + conversationClientsInCallUpdater(conversationIdWithDomain) + epochInfoUpdater(conversationIdWithDomain) + } + } + + override fun onActiveSpeakersChanged(handle: UInt, conversationId: String?, data: String?) { + if (conversationId == null || data == null) return + val callActiveSpeakers = Json.decodeFromString(data) + val activeSpeakers = callActiveSpeakers.activeSpeakers.filter { activeSpeaker -> + activeSpeaker.audioLevel > 0 || activeSpeaker.audioLevelNow > 0 + }.groupBy({ qualifiedIdMapper.fromStringToQualifiedID(it.userId) }) { it.clientId } + callRepository.updateParticipantsActiveSpeaker(qualifiedIdMapper.fromStringToQualifiedID(conversationId), activeSpeakers) + } + + override fun onMuteStateChanged(isMuted: Boolean) { + scope.launch { + callRepository.establishedCallsFlow().first().firstOrNull()?.conversationId?.let { + callRepository.updateIsMutedById(it, isMuted) + } + } + } + } + + private fun enqueueCallingMessage( + context: COpaquePointer?, + callHostConversationId: ConversationId, + messageString: String, + avsSelfUserId: UserId, + avsSelfClientId: ClientId, + messageTarget: CallingMessageTarget + ) { + scope.launch { + val transportConversationIds = when (messageTarget) { + is CallingMessageTarget.Self -> selfConversationIdProvider() + is CallingMessageTarget.HostConversation -> Either.Right(listOf(callHostConversationId)) + } + val result = transportConversationIds.flatMap { conversations -> + conversations.foldToEitherWhileRight(Unit) { transportConversationId, _ -> + sendCallingMessage( + callHostConversationId = callHostConversationId, + userId = avsSelfUserId, + clientId = avsSelfClientId, + data = messageString, + messageTarget = messageTarget.specificTarget, + transportConversationId = transportConversationId + ) + } + } + val (code, message) = when (result) { + is Either.Right -> 200 to "" + is Either.Left -> 400 to "Couldn't send Calling Message" + } + AppleAvsInterop.respondToSend(deferredHandle.await(), code, message, context) + } + } + + private suspend fun sendCallingMessage( + callHostConversationId: ConversationId, + userId: UserId, + clientId: ClientId, + data: String, + messageTarget: MessageTarget, + transportConversationId: ConversationId + ) = messageSender.sendMessage( + Message.Signaling( + id = Uuid.random().toString(), + content = MessageContent.Calling(data, callHostConversationId), + conversationId = transportConversationId, + date = kotlinx.datetime.Clock.System.now(), + senderUserId = userId, + senderClientId = clientId, + status = Message.Status.Sent, + isSelfMessage = true, + expirationData = null + ), + messageTarget + ) + + private suspend fun handleParticipantsChanged(conversationId: String, data: String) { + val participantsChange = Json.decodeFromString(data) + val conversationIdWithDomain = qualifiedIdMapper.fromStringToQualifiedID(conversationId) + val participants = participantsChange.members.map { member -> + participantMapper.fromCallMemberToParticipantMinimized(member) + } + if (callHelper.shouldEndSFTOneOnOneCall(conversationIdWithDomain, participants)) { + endCall(conversationIdWithDomain) + } + callRepository.updateCallParticipants(conversationIdWithDomain, participants) + } + + private fun handleClosedCall(reason: Int, conversationId: String) { + val avsReason = CallClosedReason.fromInt(reason) + val callStatus = getCallStatusFromCloseReason(avsReason) + val conversationIdWithDomain = qualifiedIdMapper.fromStringToQualifiedID(conversationId) + + scope.launch { + val callMetadata = callRepository.getCallMetadata(conversationIdWithDomain) + val isConnectedToInternet = networkStateObserver.observeNetworkState().value == NetworkState.ConnectedWithInternet + if (isConnectedToInternet && shouldPersistMissedCall(callMetadata, callStatus)) { + callRepository.persistMissedCall(conversationIdWithDomain) + } + + val shouldUpdateCallStatus = if (callMetadata?.conversationType == Conversation.Type.OneOnOne) { + when (callMetadata.callStatus) { + CallStatus.MISSED, + CallStatus.REJECTED, + CallStatus.CLOSED -> false + else -> true + } + } else { + true + } + + if (shouldUpdateCallStatus) { + callRepository.updateCallStatusById(conversationIdWithDomain, callStatus) + } + if (callMetadata?.protocol is Conversation.ProtocolInfo.MLS) { + callRepository.leaveMlsConference(conversationIdWithDomain) + } + } + + scope.launch { createAndPersistRecentlyEndedCallMetadata(conversationIdWithDomain, reason) } + } + + private fun shouldPersistMissedCall(callMetadata: CallMetadata?, callStatus: CallStatus): Boolean = when (callStatus) { + CallStatus.MISSED -> true + CallStatus.CLOSED -> callMetadata?.let { metadata -> + metadata.establishedTime.isNullOrEmpty() && + metadata.callStatus != CallStatus.CLOSED_INTERNALLY && + metadata.callStatus != CallStatus.REJECTED && + metadata.callStatus != CallStatus.STARTED + } ?: false + else -> false + } + + private fun getCallStatusFromCloseReason(reason: CallClosedReason): CallStatus = when (reason) { + CallClosedReason.STILL_ONGOING -> CallStatus.STILL_ONGOING + CallClosedReason.CANCELLED -> CallStatus.MISSED + CallClosedReason.REJECTED -> CallStatus.REJECTED + else -> CallStatus.CLOSED + } + + private fun onCallingReady() { + callingLogger.i("$tagWithUserId: iOS calling ready") + } + + internal suspend fun waitUntilInitialized() { + deferredHandle.await() + } + + private sealed interface CallingMessageTarget { + val specificTarget: MessageTarget + + data object Self : CallingMessageTarget { + override val specificTarget: MessageTarget = MessageTarget.Conversation() + } + + data class HostConversation(override val specificTarget: MessageTarget = MessageTarget.Conversation()) : CallingMessageTarget + } + + internal companion object { + private const val DEFAULT_REQUEST_VIDEO_STREAMS_MODE = 0 + private const val TAG = "CallManager" + private val DEFAULT_WAIT_UNTIL_CONNECTED_TIMEOUT = 15.seconds } } diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/FlowManagerServiceImpl.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/FlowManagerServiceImpl.kt index 80b87b41e03d..8719dd6d8509 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/FlowManagerServiceImpl.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/FlowManagerServiceImpl.kt @@ -18,6 +18,9 @@ package com.wire.kalium.logic.feature.call +import avs.AVSFlowManager +import avs.AVSMediaManager +import com.wire.kalium.common.logger.callingLogger import com.wire.kalium.common.logger.kaliumLogger import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.util.PlatformContext @@ -27,23 +30,46 @@ import com.wire.kalium.logic.util.PlatformView internal actual open class FlowManagerServiceImpl( appContext: PlatformContext ) : FlowManagerService { + private val mediaManager by lazy { + AVSMediaManager.defaultMediaManager() ?: AVSMediaManager() + } + + private val flowManager by lazy { + AVSFlowManager(delegate = null, mediaManager = mediaManager).also { + it.setEnableLogging(true) + } + } + actual override suspend fun setVideoPreview(conversationId: ConversationId, view: PlatformView) { - kaliumLogger.w("Calls not supported on iOS: setVideoPreview ignored") + val videoView = view.view + if (videoView == null) { + kaliumLogger.w("AVS iOS: setVideoPreview ignored because view is null") + return + } + flowManager.attachVideoView(videoView) + callingLogger.i("AVS iOS: attached remote video view for conversation=${conversationId.value}") } actual override suspend fun flipToFrontCamera(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: flipToFrontCamera ignored") + flowManager.setVideoCaptureDevice(deviceId = "front", forConversation = conversationId.toString()) + callingLogger.i("AVS iOS: switched to front camera for conversation=${conversationId.value}") } actual override suspend fun flipToBackCamera(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: flipToBackCamera ignored") + flowManager.setVideoCaptureDevice(deviceId = "back", forConversation = conversationId.toString()) + callingLogger.i("AVS iOS: switched to back camera for conversation=${conversationId.value}") } actual override suspend fun setUIRotation(rotation: PlatformRotation) { - kaliumLogger.w("Calls not supported on iOS: setUIRotation ignored") + kaliumLogger.w("AVS iOS: setUIRotation not exposed in current AVSFlowManager API") } actual override suspend fun startFlowManager() { - kaliumLogger.w("Calls not supported on iOS: startFlowManager ignored") + if (!AppleAvsInterop.startIfAvailable()) { + kaliumLogger.w("AVS iOS smoke: startFlowManager could not start AVS") + return + } + mediaManager.startAudio() + flowManager } } diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/GlobalCallManager.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/GlobalCallManager.kt index 75a1a8948b3c..aaac8ec81936 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/GlobalCallManager.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/GlobalCallManager.kt @@ -38,48 +38,17 @@ import com.wire.kalium.logic.feature.call.usecase.CreateAndPersistRecentlyEndedC import com.wire.kalium.messaging.sending.MessageSender import com.wire.kalium.logic.featureFlags.KaliumConfigs import com.wire.kalium.network.NetworkStateObserver -import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EpochInfoUpdater -import com.wire.kalium.logic.util.PlatformRotation -import com.wire.kalium.logic.util.PlatformView +import com.wire.kalium.logic.util.PlatformContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flowOf internal actual class GlobalCallManager( scope: CoroutineScope, networkStateObserver: NetworkStateObserver ) : CallNetworkChangeManager(scope, networkStateObserver) { - private val flowManagerService = object : FlowManagerService { - override suspend fun setVideoPreview(conversationId: ConversationId, view: PlatformView) { - kaliumLogger.w("Calls not supported on iOS: setVideoPreview ignored") - } - override suspend fun flipToFrontCamera(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: flipToFrontCamera ignored") - } - override suspend fun flipToBackCamera(conversationId: ConversationId) { - kaliumLogger.w("Calls not supported on iOS: flipToBackCamera ignored") - } - override suspend fun setUIRotation(rotation: PlatformRotation) { - kaliumLogger.w("Calls not supported on iOS: setUIRotation ignored") - } - override suspend fun startFlowManager() { - kaliumLogger.w("Calls not supported on iOS: startFlowManager ignored") - } - } - - private val mediaManagerService = object : MediaManagerService { - override suspend fun turnLoudSpeakerOn() { - kaliumLogger.w("Calls not supported on iOS: turnLoudSpeakerOn ignored") - } - override suspend fun turnLoudSpeakerOff() { - kaliumLogger.w("Calls not supported on iOS: turnLoudSpeakerOff ignored") - } - override fun observeSpeaker() = flowOf(false) - override suspend fun startMediaManager() { - kaliumLogger.w("Calls not supported on iOS: startMediaManager ignored") - } - } + private val flowManagerService by lazy { FlowManagerServiceImpl(PlatformContext()) } + private val mediaManagerService by lazy { MediaManagerServiceImpl(PlatformContext()) } @Suppress("LongParameterList") internal actual fun getCallManagerForClient( @@ -101,7 +70,26 @@ internal actual class GlobalCallManager( kaliumConfigs: KaliumConfigs, createAndPersistRecentlyEndedCallMetadata: CreateAndPersistRecentlyEndedCallMetadataUseCase ): CallManager { - return CallManagerImpl() + return CallManagerImpl( + callRepository = callRepository, + currentClientIdProvider = currentClientIdProvider, + selfConversationIdProvider = selfConversationIdProvider, + messageSender = messageSender, + callMapper = callMapper, + federatedIdMapper = federatedIdMapper, + qualifiedIdMapper = qualifiedIdMapper, + videoStateChecker = videoStateChecker, + conversationClientsInCallUpdater = conversationClientsInCallUpdater, + epochInfoUpdater = epochInfoUpdater, + networkStateObserver = networkStateObserver, + getCallConversationType = getCallConversationType, + userConfigRepository = userConfigRepository, + kaliumConfigs = kaliumConfigs, + mediaManagerService = mediaManagerService, + flowManagerService = flowManagerService, + createAndPersistRecentlyEndedCallMetadata = createAndPersistRecentlyEndedCallMetadata, + selfUserId = userId + ) } actual suspend fun removeInMemoryCallingManagerForUser(userId: UserId) { @@ -117,6 +105,8 @@ internal actual class GlobalCallManager( } actual override fun networkChanged() { - kaliumLogger.w("Calls not supported on iOS: networkChanged ignored") + if (!AppleAvsInterop.notifyNetworkChangedIfAvailable()) { + kaliumLogger.w("AVS iOS smoke: networkChanged ignored because AVS is unavailable") + } } } diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt index 2d65900eba60..e752d386d26b 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt @@ -18,4 +18,8 @@ package com.wire.kalium.logic.util -public actual class PlatformView +import platform.UIKit.UIView + +public actual class PlatformView( + public val view: UIView? +) From 328159cea89a2746937953ff9e8835c699fb17a5 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 12 May 2026 12:20:35 +0200 Subject: [PATCH 2/4] fix: scope AVS KMP dependency to iOS source sets --- domain/calling/build.gradle.kts | 7 +++++++ gradle/libs.versions.toml | 1 + 2 files changed, 8 insertions(+) diff --git a/domain/calling/build.gradle.kts b/domain/calling/build.gradle.kts index 7eceeaeaaf7c..11301a4fb394 100644 --- a/domain/calling/build.gradle.kts +++ b/domain/calling/build.gradle.kts @@ -66,6 +66,13 @@ kotlin { implementation(libs.jna) } } + matching { sourceSet -> + sourceSet.name.startsWith("ios") && sourceSet.name.endsWith("Main") + }.all { + dependencies { + api(libs.avsKmp) + } + } val commonTest by getting { dependencies { } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ca2feeba5e5..b550f2abfbcd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -233,6 +233,7 @@ difference-jvm = { module = "dev.andrewbailey.difference:difference-jvm", versio # avs avs = { module = "com.wire:avs", version.ref = "avs" } +avsKmp = { module = "com.wire:avs-kmp", version.ref = "avs" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } # logging From bc9cd41b2a29162735a96cf3fc338eb1959f2683 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 12 May 2026 12:54:31 +0200 Subject: [PATCH 3/4] detekt fix --- .../logic/feature/call/AppleAvsInterop.kt | 22 ++++++++++++++++--- .../logic/feature/call/CallManagerImpl.kt | 16 +++++++++----- .../wire/kalium/logic/util/PlatformView.kt | 6 +++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt index 8c22c77605a2..14286d5dc556 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/AppleAvsInterop.kt @@ -60,7 +60,7 @@ import kotlinx.cinterop.staticCFunction import kotlinx.cinterop.toKString import kotlinx.cinterop.usePinned -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "ReturnCount", "TooManyFunctions") internal object AppleAvsInterop { interface Callbacks { fun onReady(version: Int) @@ -76,7 +76,15 @@ internal object AppleAvsInterop { myClientsOnly: Boolean ): Int fun onSftRequest(context: COpaquePointer?, url: String?, data: ByteArray): Int - fun onIncomingCall(conversationId: String?, messageTime: UInt, userId: String?, clientId: String?, video: Boolean, shouldRing: Boolean, conversationType: Int) + fun onIncomingCall( + conversationId: String?, + messageTime: UInt, + userId: String?, + clientId: String?, + video: Boolean, + shouldRing: Boolean, + conversationType: Int + ) fun onMissedCall(conversationId: String?, messageTime: UInt, userId: String?, video: Boolean) fun onAnsweredCall(conversationId: String?) fun onEstablishedCall(conversationId: String?, userId: String?, clientId: String?) @@ -150,7 +158,15 @@ internal object AppleAvsInterop { shouldRing: Int, conversationType: Int, arg: COpaquePointer? -> - callbacks(arg)?.onIncomingCall(conversationId.string(), msgTime, userId.string(), clientId.string(), video != 0, shouldRing != 0, conversationType) + callbacks(arg)?.onIncomingCall( + conversationId.string(), + msgTime, + userId.string(), + clientId.string(), + video != 0, + shouldRing != 0, + conversationType + ) Unit } diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt index 256e13481b2b..1121178d688a 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt @@ -25,7 +25,6 @@ import com.wire.kalium.calling.CallTypeCalling import com.wire.kalium.calling.ConversationTypeCalling import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.flatMap -import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.foldToEitherWhileRight import com.wire.kalium.common.functional.nullableFold import com.wire.kalium.common.logger.callingLogger @@ -76,7 +75,6 @@ import kotlinx.cinterop.COpaquePointer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -386,7 +384,7 @@ internal class CallManagerImpl internal constructor( messageTarget = messageTarget ) AvsCallBackError.NONE.value - } catch (e: Exception) { + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { callingLogger.e("[OnSendOTR/iOS] -> Error Exception: $e") AvsCallBackError.COULD_NOT_DECODE_ARGUMENT.value } @@ -403,7 +401,11 @@ internal class CallManagerImpl internal constructor( return@launch } val dataString = data.decodeToString() - val responseData = callRepository.connectToSFT(url = url, data = dataString).nullableFold({ null }, { it }) ?: byteArrayOf() + val responseData = callRepository.connectToSFT(url = url, data = dataString) + .nullableFold( + { null }, + { it } + ) ?: byteArrayOf() val error = if (responseData.isEmpty()) AvsSFTError.NO_RESPONSE_DATA.value else AvsSFTError.NONE.value AppleAvsInterop.respondToSft(deferredHandle.await(), error, responseData, context) } @@ -559,8 +561,8 @@ internal class CallManagerImpl internal constructor( } } val (code, message) = when (result) { - is Either.Right -> 200 to "" - is Either.Left -> 400 to "Couldn't send Calling Message" + is Either.Right -> AVS_SEND_SUCCESS_STATUS_CODE to "" + is Either.Left -> AVS_SEND_FAILURE_STATUS_CODE to "Couldn't send Calling Message" } AppleAvsInterop.respondToSend(deferredHandle.await(), code, message, context) } @@ -672,6 +674,8 @@ internal class CallManagerImpl internal constructor( internal companion object { private const val DEFAULT_REQUEST_VIDEO_STREAMS_MODE = 0 + private const val AVS_SEND_SUCCESS_STATUS_CODE = 200 + private const val AVS_SEND_FAILURE_STATUS_CODE = 400 private const val TAG = "CallManager" private val DEFAULT_WAIT_UNTIL_CONNECTED_TIMEOUT = 15.seconds } diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt index e752d386d26b..a9e85a533bb0 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/util/PlatformView.kt @@ -20,6 +20,12 @@ package com.wire.kalium.logic.util import platform.UIKit.UIView +/** + * Native iOS view wrapper used by calling APIs that need a platform rendering surface. + * + * For AVS video calls, the iOS UI owns the [UIView] lifecycle and passes it through this wrapper. + * Kalium forwards [view] to AVS so remote video can be rendered into the native UI surface. + */ public actual class PlatformView( public val view: UIView? ) From dd064c0c72c39e1b65060e2e46b3378b91bcc81c Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 12 May 2026 14:23:18 +0200 Subject: [PATCH 4/4] import fix --- .../kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt index 1121178d688a..f20bc94ca416 100644 --- a/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt +++ b/logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/call/CallManagerImpl.kt @@ -25,6 +25,7 @@ import com.wire.kalium.calling.CallTypeCalling import com.wire.kalium.calling.ConversationTypeCalling import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.foldToEitherWhileRight import com.wire.kalium.common.functional.nullableFold import com.wire.kalium.common.logger.callingLogger