diff --git a/android/app/build.gradle b/android/app/build.gradle index 11428239..569cd689 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -39,7 +39,7 @@ android { versionName = flutter.versionName manifestPlaceholders = [ YANDEX_CLIENT_ID: yandexClientId, - applicationName: "io.flutter.app.FlutterApplication" + applicationName: "com.eatvioapp.app.EatvioApplication" ] } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt index 3b6d3732..248d1d1f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt @@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper { return null } - return hashMapOf( - "action" to action, - "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), - "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), - "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), - "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), - "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), - ) + return payloadMap(data, action) + } + + fun declinePayloadFromBundle(data: Bundle, action: String): Map? { + if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null + return payloadMap(data, action) } fun payloadFromIntent(intent: Intent?): Map? { @@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper { return null } + return payloadMap(data, action) + } + + fun clearCallKitLaunchIntent(intent: Intent?) { + if (intent == null) return + intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + intent.action = null + } + + private fun payloadMap(data: Bundle, action: String): Map { return hashMapOf( "action" to action, "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), @@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper { "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), ) } - - fun clearCallKitLaunchIntent(intent: Intent?) { - if (intent == null) return - intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) - intent.action = null - } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index 070462bc..95e887fe 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -16,6 +16,12 @@ object CallKitIntentBridge { CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> PendingCallKitAcceptBridge.store(payload) } + CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload -> + PendingCallKitDeclineBridge.store(payload) + if (MainActivity.isInForeground) { + MainActivity.onPendingDecline?.invoke() + } + } } val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt new file mode 100644 index 00000000..89b4b201 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt @@ -0,0 +1,10 @@ +package com.eatvioapp.app + +import android.app.Application + +class EatvioApplication : Application() { + override fun onCreate() { + super.onCreate() + VideoCallDeclineReporter.install(this) + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt index 48ef2a5f..26e623d1 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() { Log.i(TAG, "onMessageReceived: ${message.data}") if (VideoCallPushBridge.isEndedVideoCall(message.data)) { - VideoCallPushBridge.dismissAllIncomingCalls(this) + VideoCallPushBridge.dismissEndedVideoCall(this, message.data) return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index 5c1c0d3e..fd3e68c7 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() { if (message == null) return if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { - VideoCallPushBridge.dismissAllIncomingCalls(this) + VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap) return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index a30cd589..4ab92064 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -25,6 +25,9 @@ class MainActivity: FlutterActivity() { @JvmStatic var isInForeground: Boolean = false private set + + @JvmStatic + var onPendingDecline: (() -> Unit)? = null } private val channelName = "com.eatvioapp/hms_push" @@ -38,13 +41,18 @@ class MainActivity: FlutterActivity() { override fun onResume() { super.onResume() isInForeground = true + onPendingDecline = { scheduleDeliverPendingCallKitDecline() } captureCallKitLaunchIntent(intent) if (PendingCallKitAcceptBridge.peek() != null) { scheduleDeliverPendingCallKitAccept() } + if (PendingCallKitDeclineBridge.peek() != null) { + scheduleDeliverPendingCallKitDecline() + } } override fun onPause() { + onPendingDecline = null isInForeground = false super.onPause() } @@ -55,11 +63,21 @@ class MainActivity: FlutterActivity() { methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName) + onPendingDecline = { scheduleDeliverPendingCallKitDecline() } videoCallPushChannel?.setMethodCallHandler { call, result -> when (call.method) { "dismissIncomingCalls" -> { try { - VideoCallPushBridge.dismissAllIncomingCalls(applicationContext) + val args = call.arguments as? Map<*, *> + val callId = args?.get("call_id")?.toString() + ?: args?.get("callId")?.toString() + val conversationUuid = args?.get("conversation_uuid")?.toString() + ?: args?.get("conversationUuid")?.toString() + VideoCallPushBridge.dismissIncomingCalls( + applicationContext, + callId = callId, + conversationUuid = conversationUuid, + ) result.success(null) } catch (e: Exception) { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) @@ -86,6 +104,9 @@ class MainActivity: FlutterActivity() { "peekPendingCallKitAccept" -> { result.success(PendingCallKitAcceptBridge.peekPending()) } + "drainPendingCallKitDecline" -> { + result.success(PendingCallKitDeclineBridge.drain()) + } else -> result.notImplemented() } } @@ -241,6 +262,30 @@ class MainActivity: FlutterActivity() { channel.invokeMethod("onCallKitLaunchIntent", payload) } + private fun scheduleDeliverPendingCallKitDecline() { + PendingCallKitDeclineBridge.scheduleDeliverToFlutter( + deliver = { deliverPendingCallKitDeclineToFlutter() }, + ) + } + + private fun deliverPendingCallKitDeclineToFlutter() { + val payload = PendingCallKitDeclineBridge.peek() ?: return + val channel = videoCallPushChannel + if (channel == null) { + android.util.Log.w( + "VideoCallPush", + "deliverPendingCallKitDeclineToFlutter: channel not ready", + ) + return + } + + android.util.Log.i( + "VideoCallPush", + "deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}", + ) + channel.invokeMethod("onCallKitDecline", payload) + } + private fun handleIntentForActionUrl(intent: Intent?) { if (intent == null) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt new file mode 100644 index 00000000..e50a23b0 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt @@ -0,0 +1,44 @@ +package com.eatvioapp.app + +import android.os.Handler +import android.os.Looper +import android.util.Log + +/// Stores a decline until Flutter can POST /video-call/decline. +/// +/// The plugin broadcasts the decline. If the Flutter engine is not attached, +/// that broadcast never reaches the backend and the call keeps ringing on the +/// user's other devices. +object PendingCallKitDeclineBridge { + private const val TAG = "PendingCallKitDecline" + + @Volatile + private var pendingPayload: Map? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun store(payload: Map) { + pendingPayload = payload + Log.i(TAG, "stored pending decline id=${payload["id"]}") + } + + fun peek(): Map? = pendingPayload + + fun drain(): Map? { + val payload = pendingPayload + pendingPayload = null + if (payload != null) { + Log.i(TAG, "drained pending decline id=${payload["id"]}") + } + return payload + } + + fun scheduleDeliverToFlutter( + deliver: () -> Unit, + delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L), + ) { + for (delayMs in delaysMs) { + mainHandler.postDelayed({ deliver() }, delayMs) + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt new file mode 100644 index 00000000..7b7c1dce --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt @@ -0,0 +1,73 @@ +package com.eatvioapp.app + +import android.content.Context +import android.util.Log +import java.net.HttpURLConnection +import java.net.URL + +internal object FlutterSharedPrefs { + const val NAME = "FlutterSharedPreferences" + private const val PREFIX = "flutter." + + fun string(context: Context, key: String): String? { + return context + .getSharedPreferences(NAME, Context.MODE_PRIVATE) + .getString(PREFIX + key, null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + } +} + +internal object VideoCallDeclineClient { + private const val TAG = "VideoCallDecline" + private const val TIMEOUT_MS = 4_000 + + fun send(context: Context, handle: String?, extra: Map<*, *>?) { + val fields = VideoCallDeclineRequestFactory.fields(handle, extra) + val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl") + ?: FlutterSharedPrefs.string(context, "customBaseUrl") + val request = VideoCallDeclineRequestFactory.build( + baseUrl = baseUrl, + token = FlutterSharedPrefs.string(context, "token"), + hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"), + fields = fields, + ) + if (request == null) { + Log.w( + TAG, + "skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}", + ) + return + } + + try { + val code = post(request) + Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}") + } catch (error: Exception) { + Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error) + } + } + + private fun post(request: VideoCallDeclineRequest): Int { + val connection = (URL(request.url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer ${request.token}") + setRequestProperty("Accept", "application/json") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + try { + connection.outputStream.use { stream -> + stream.write(request.body.toByteArray(Charsets.UTF_8)) + } + val code = connection.responseCode + val response = if (code in 200..299) connection.inputStream else connection.errorStream + response?.use { stream -> stream.readBytes() } + return code + } finally { + connection.disconnect() + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt new file mode 100644 index 00000000..130da466 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt @@ -0,0 +1,74 @@ +package com.eatvioapp.app + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.core.os.BundleCompat +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitEventCallback +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Sends `POST /video-call/decline` from the process that handled the tap. + * + * The notification Decline button is an explicit broadcast into the CallKit + * plugin. That broadcast starts the process when the app was killed and does + * not start Flutter. The plugin notifies this callback before it returns, so + * the request can finish while the process is still alive. + */ +object VideoCallDeclineReporter : CallkitEventCallback { + private const val TAG = "VideoCallDecline" + private const val WAIT_FOR_HTTP_MS = 9_000L + + @Volatile + private var appContext: Context? = null + + @Volatile + private var installed = false + + fun install(context: Context) { + if (installed) return + installed = true + appContext = context.applicationContext + FlutterCallkitIncomingPlugin.registerEventCallback(this) + Log.i(TAG, "installed") + } + + override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) { + if (event != CallkitEventCallback.CallEvent.DECLINE) return + val context = appContext ?: return + val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE) + val extra = copyExtra(callData) + if (MainActivity.isInForeground) { + CoroutineScope(Dispatchers.IO).launch { + VideoCallDeclineClient.send(context, handle, extra) + } + return + } + // The Decline button starts this process and then the broadcast ends. + // Wait here so Android does not kill the process before the POST returns. + runBlocking(Dispatchers.IO) { + withTimeoutOrNull(WAIT_FOR_HTTP_MS) { + VideoCallDeclineClient.send(context, handle, extra) + } + } + } + + private fun copyExtra(callData: Bundle): Map { + val raw = BundleCompat.getSerializable( + callData, + CallkitConstants.EXTRA_CALLKIT_EXTRA, + HashMap::class.java, + ) ?: return emptyMap() + return buildMap { + for ((key, value) in raw) { + if (key is String) put(key, value) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt new file mode 100644 index 00000000..6f851403 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt @@ -0,0 +1,136 @@ +package com.eatvioapp.app + +/** + * Body of `POST /api/v2/chat/{conversation}/video-call/decline`. + * + * [callId] is omitted when it is missing, not a uuid, or equal to the + * conversation uuid: the server treats `call_id` as the session uuid. + */ +internal data class VideoCallDeclineRequest( + val url: String, + val token: String, + val body: String, +) + +internal data class VideoCallDeclineFields( + val conversationUuid: String, + val callId: String?, + val endedPush: Boolean, +) + +internal object VideoCallDeclineRequestFactory { + private val HEX = "0123456789ABCDEF".toCharArray() + private val uuidPattern = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields { + val conversation = firstString( + extra, + listOf("conversationUuid", "conversation_uuid"), + ) ?: handle?.trim().orEmpty() + val rawCallId = firstString(extra, listOf("call_id", "callId")) + return VideoCallDeclineFields( + conversationUuid = conversation, + callId = backendCallId(rawCallId, conversation), + endedPush = marksEndedPush(extra), + ) + } + + fun build( + baseUrl: String?, + token: String?, + hwId: String?, + fields: VideoCallDeclineFields, + ): VideoCallDeclineRequest? { + if (fields.endedPush) return null + val conversation = fields.conversationUuid.trim() + val authToken = token?.trim().orEmpty() + val deviceId = hwId?.trim().orEmpty() + val root = baseUrl?.trim()?.trimEnd('/').orEmpty() + if (conversation.isEmpty() || authToken.isEmpty()) return null + if (deviceId.length < 8 || deviceId.length > 64) return null + if (!root.startsWith("http://") && !root.startsWith("https://")) return null + + val encoded = encodePathSegment(conversation) + return VideoCallDeclineRequest( + url = "$root/api/v2/chat/$encoded/video-call/decline", + token = authToken, + body = jsonBody(deviceId, fields.callId), + ) + } + + private fun backendCallId(callId: String?, conversationUuid: String): String? { + val id = callId?.trim().orEmpty() + if (!uuidPattern.matches(id)) return null + if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null + return id + } + + private fun firstString(extra: Map<*, *>?, keys: List): String? { + if (extra == null) return null + for (key in keys) { + val value = extra[key] as? String ?: continue + val trimmed = value.trim() + if (trimmed.isNotEmpty()) return trimmed + } + return null + } + + private fun marksEndedPush(extra: Map<*, *>?): Boolean { + val value = extra?.get("endedPush") ?: return false + return when (value) { + is Boolean -> value + is Number -> value.toInt() != 0 + is String -> { + val normalized = value.trim().lowercase() + normalized == "true" || normalized == "1" + } + else -> false + } + } + + private fun jsonBody(hwId: String, callId: String?): String { + val hw = jsonString(hwId) + if (callId == null) return """{"hw_id":$hw}""" + return """{"hw_id":$hw,"call_id":${jsonString(callId)}}""" + } + + private fun encodePathSegment(value: String): String { + val bytes = value.encodeToByteArray() + return buildString(bytes.size) { + for (byte in bytes) { + val code = byte.toInt() and 0xFF + val allowed = code in 'A'.code..'Z'.code || + code in 'a'.code..'z'.code || + code in '0'.code..'9'.code || + code == '-'.code || + code == '_'.code || + code == '.'.code || + code == '~'.code + if (allowed) { + append(code.toChar()) + } else { + append('%') + append(HEX[code ushr 4]) + append(HEX[code and 0x0F]) + } + } + } + } + + private fun jsonString(value: String): String { + val escaped = buildString { + for (char in value) { + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(char) + } + } + } + return "\"$escaped\"" + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 50afdac6..cf915ba8 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -18,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls +import com.hiennv.flutter_callkit_incoming.removeCall import java.util.HashMap import java.util.concurrent.atomic.AtomicInteger @@ -25,11 +26,13 @@ object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring" private val mainHandler = Handler(Looper.getMainLooper()) private val playbackLock = Any() private val playbackGeneration = AtomicInteger(0) + private val tombstonedCallIds = HashMap() private var activeSoundManager: CallkitSoundPlayerManager? = null private var activeNotificationManager: CallkitNotificationManager? = null @@ -118,6 +121,12 @@ object VideoCallPushBridge { storedAppContext = appContext ensureIncomingCallCallbackRegistered() + val serverCallId = callId(data) + if (isTombstoned(serverCallId)) { + Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId") + return + } + val callData = buildCallDataBundle(data) if (callData == null) { Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") @@ -126,7 +135,7 @@ object VideoCallPushBridge { val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID) val seenGeneration = playbackGeneration.get() - Log.i(TAG, "showIncomingCall: conversation=$callId") + Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}") // Ringtone.play() and stop() must run on the same thread. FCM delivers // pushes on a background thread, while Answer runs on the main thread. @@ -180,17 +189,43 @@ object VideoCallPushBridge { } } - fun dismissAllIncomingCalls(context: Context) { + fun dismissIncomingCalls( + context: Context, + callId: String? = null, + conversationUuid: String? = null, + ) { + val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() } + val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedCallId != null) { + markTombstone(normalizedCallId) + } + val appContext = context.applicationContext val calls = getDataActiveCalls(appContext) - Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + Log.i( + TAG, + "dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " + + "conversation=${normalizedConversation ?: "-"}", + ) playbackGeneration.incrementAndGet() val dismiss = Runnable { synchronized(playbackLock) { val notificationManager = resolveNotificationManager(appContext) - val bundles = calls.map { it.toBundle() } - stopPlaybackLocked(appContext) + val allBundles = calls.map { it.toBundle() } + val targeted = hasDismissTarget(normalizedCallId, normalizedConversation) + val bundles = if (targeted) { + allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) } + } else { + allBundles + } + + val activeMatches = activeCallBundle?.let { bundle -> + !targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation) + } == true + if (activeMatches || !targeted) { + stopPlaybackLocked(appContext) + } for (bundle in bundles) { try { @@ -198,25 +233,39 @@ object VideoCallPushBridge { } catch (error: Exception) { Log.w( TAG, - "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", + "dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", error, ) } } - try { - CallkitNotificationService.stopService(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) + if (!targeted || bundles.isNotEmpty()) { + try { + CallkitNotificationService.stopService(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error) + } } - try { - removeAllCalls(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + if (!targeted) { + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error) + } + } else { + for (bundle in bundles) { + try { + removeCall(appContext, Data.fromBundle(bundle)) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error) + } + } } - stopIncomingRingtone() + if (activeMatches || !targeted) { + stopIncomingRingtone() + } scheduleMissedCallCleanup(notificationManager, bundles) } } @@ -228,6 +277,18 @@ object VideoCallPushBridge { } } + fun dismissAllIncomingCalls(context: Context) { + dismissIncomingCalls(context) + } + + fun dismissEndedVideoCall(context: Context, data: Map) { + dismissIncomingCalls( + context = context, + callId = callId(data), + conversationUuid = conversationUuid(data), + ) + } + private fun ensureIncomingCallCallbackRegistered() { if (callbackRegistered) return FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback) @@ -403,6 +464,8 @@ object VideoCallPushBridge { private fun buildCallDataBundle(data: Map): Bundle? { val conversationUuid = conversationUuid(data) ?: return null + val serverCallId = callId(data) + val callKitId = serverCallId ?: conversationUuid val callerName = data["counterpartName"] ?: data["counterpart_name"] ?: data["caller_name"] @@ -428,6 +491,9 @@ object VideoCallPushBridge { val extra = HashMap() extra["conversationUuid"] = conversationUuid + if (!serverCallId.isNullOrBlank()) { + extra["call_id"] = serverCallId + } extra["counterpartName"] = callerName if (avatarUrl.isNotBlank()) { extra["counterpartAvatarUrl"] = avatarUrl @@ -438,9 +504,13 @@ object VideoCallPushBridge { if (counterpartIsConsultant) { extra["counterpartIsConsultant"] = true } + val messageId = data["message_id"] ?: data["messageId"] + if (!messageId.isNullOrBlank()) { + extra["message_id"] = messageId + } return Bundle().apply { - putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId) putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) @@ -465,6 +535,77 @@ object VideoCallPushBridge { } } + private fun callId(data: Map): String? { + return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() } + ?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean { + return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank() + } + + private fun bundleMatches( + bundle: Bundle, + callId: String?, + conversationUuid: String?, + ): Boolean { + val normalizedCallId = callId?.trim()?.lowercase() + if (!normalizedCallId.isNullOrEmpty()) { + val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase() + if (id != null && id == normalizedCallId) return true + val extraCallId = extraString(bundle, "call_id") + ?: extraString(bundle, "callId") + if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) { + return true + } + // A ring that already stores its own call id must not be closed by + // another call in the same chat. Legacy rings have no call id and + // still match the conversation. + if (!extraCallId.isNullOrBlank()) return false + } + + val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false + val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase() + if (handle != null && handle == normalizedConversation) return true + val extraConversation = extraString(bundle, "conversationUuid") + ?: extraString(bundle, "conversation_uuid") + return extraConversation?.lowercase() == normalizedConversation + } + + @Suppress("UNCHECKED_CAST") + private fun extraString(bundle: Bundle, key: String): String? { + val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap + ?: return null + val value = extra[key] ?: return null + val text = value.toString().trim() + return text.takeIf { it.isNotEmpty() } + } + + private fun markTombstone(callId: String) { + val normalized = callId.trim().lowercase() + if (normalized.isEmpty()) return + pruneTombstones() + tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS + } + + private fun isTombstoned(callId: String?): Boolean { + val normalized = callId?.trim()?.lowercase() ?: return false + if (normalized.isEmpty()) return false + pruneTombstones() + return tombstonedCallIds.containsKey(normalized) + } + + private fun pruneTombstones() { + val now = System.currentTimeMillis() + val iterator = tombstonedCallIds.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.value <= now) { + iterator.remove() + } + } + } + private fun conversationUuid(data: Map): String? { val fromData = data["conversationUuid"] ?: data["conversation_uuid"] diff --git a/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt b/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt new file mode 100644 index 00000000..e6e5397f --- /dev/null +++ b/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt @@ -0,0 +1,103 @@ +package com.eatvioapp.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VideoCallDeclineRequestTest { + private val baseUrl = "https://eatvio.example" + private val token = "sanctum-token" + private val hwId = "install-hw-id-01" + private val conversation = "11111111-1111-1111-1111-111111111111" + private val callId = "22222222-2222-2222-2222-222222222222" + + @Test + fun buildsDeclinePostWithCallId() { + val request = request(callId = callId) + + assertEquals( + "https://eatvio.example/api/v2/chat/$conversation/video-call/decline", + request?.url, + ) + assertEquals(token, request?.token) + assertEquals( + """{"hw_id":"$hwId","call_id":"$callId"}""", + request?.body, + ) + } + + @Test + fun omitsCallIdWhenItIsTheConversationUuid() { + val request = request(callId = conversation.uppercase()) + + assertEquals("""{"hw_id":"$hwId"}""", request?.body) + } + + @Test + fun omitsCallIdThatIsNotAUuid() { + val request = request(callId = "message-15") + + assertEquals("""{"hw_id":"$hwId"}""", request?.body) + } + + @Test + fun readsConversationFromHandleWhenExtraHasNone() { + val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap()) + val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields) + + assertTrue(request?.url?.contains(conversation) == true) + } + + @Test + fun skipsEndedPush() { + val fields = VideoCallDeclineRequestFactory.fields( + conversation, + mapOf("conversationUuid" to conversation, "endedPush" to true), + ) + + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)) + } + + @Test + fun skipsMissingTokenBaseUrlOrShortHwId() { + val fields = VideoCallDeclineRequestFactory.fields( + null, + mapOf("conversationUuid" to conversation, "call_id" to callId), + ) + + assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields)) + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields)) + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields)) + assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields)) + } + + @Test + fun trimsATrailingSlashOnTheBaseUrl() { + val request = VideoCallDeclineRequestFactory.build( + "$baseUrl/", + token, + hwId, + VideoCallDeclineRequestFactory.fields( + null, + mapOf("conversationUuid" to conversation), + ), + ) + + assertEquals( + "https://eatvio.example/api/v2/chat/$conversation/video-call/decline", + request?.url, + ) + } + + private fun request(callId: String?): VideoCallDeclineRequest? { + val fields = VideoCallDeclineRequestFactory.fields( + null, + mapOf( + "conversationUuid" to conversation, + "call_id" to callId, + ), + ) + return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields) + } +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index fa7c1a97..e7d78ded 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 15e4ffe4..76a8087f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; }; C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -38,6 +39,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = ""; }; C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; @@ -117,6 +119,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */, C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, FEE92A0E2D772EA300E6E989 /* Runner.entitlements */, @@ -303,6 +306,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */, C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b340b48c..488ea9c0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -19,9 +19,10 @@ import flutter_foreground_task private let pendingAcceptedCallTtl: TimeInterval = 120 private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" + private var callAudioChannel: FlutterMethodChannel? private let appUrlScheme = "eatvio" - /// CallKit is only the ringing UI. After accept the in-app call continues, - /// so the audio session must be handed back to WebRTC when CallKit deactivates. + /// True only if CallKit ended while the in-app call must keep audio. + /// Incoming accept keeps CallKit up, so this stays false after answer. private var resumeAudioAfterCallKitEnds = false /// The push handler and the Flutter channel touch this map on different /// queues. The push writes the id before CallKit has the call, so the @@ -29,9 +30,20 @@ import flutter_foreground_task private var reservedIncomingCallIds: [String: Date] = [:] private let incomingCallClaimLock = NSLock() private let incomingCallClaimTtl: TimeInterval = 90 + /// Cancelled / declined server call ids. A late `video_call_started` with the + /// same id must not show ringing again (same TTL as Android / Dart). + private var tombstonedCallIds: [String: Date] = [:] + /// CallKit ids closed by an ended VoIP push. `onDecline` sees the original + /// ring, which has no `endedPush` marker, and must not POST /decline. + private var remoteEndedCallKitIds = Set() + private let remoteEndedCallLock = NSLock() + private let callTombstoneTtl: TimeInterval = 120 private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip") private var voipCallReporterStorage: VoipCallReporter? private var audioRestartGeneration = 0 + /// CallKit owns AVAudioSession from answer until the CallKit call ends. + /// WebRTC must not call setActive(true) on top of that session. + private var callKitOwnsAudioSession = false private var voipCallReporter: VoipCallReporter { if let voipCallReporterStorage { @@ -108,6 +120,7 @@ import flutter_foreground_task name: callAudioSessionChannelName, binaryMessenger: flutterController.binaryMessenger ) + self.callAudioChannel = callAudioChannel callAudioChannel.setMethodCallHandler { [weak self] call, result in guard let self else { result(nil) @@ -187,14 +200,19 @@ import flutter_foreground_task ] ) if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + var extra: [String: Any] = ["conversationUuid": conversationUuid] + if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value { + let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines) + if !serverCallId.isEmpty { + extra["call_id"] = serverCallId + } + } voipDiagnosticsChannel?.invokeMethod( "acceptedVoipCall", arguments: [ "id": callKitId, "handle": conversationUuid, - "extra": [ - "conversationUuid": conversationUuid - ], + "extra": extra, ] ) } @@ -223,6 +241,78 @@ import flutter_foreground_task return (String(conversationUuid[prefixRange]) + suffix).lowercased() } + /// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived + /// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`). + private func resolveCallKitId( + serverCallId: String?, + conversationUuid: String, + messageId: UInt64? + ) -> (id: String, source: String) { + if let serverCallId, !serverCallId.isEmpty { + return (serverCallId, "call_id") + } + if let derived = derivedCallKitId( + conversationUuid: conversationUuid, + messageId: messageId + ) { + return (derived, "conversation_and_message") + } + if UUID(uuidString: conversationUuid) != nil { + return (conversationUuid.lowercased(), "conversation") + } + return (UUID().uuidString.lowercased(), "generated") + } + + private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? { + for key in ["call_id", "callId"] { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private func voipEndReason(from dict: [AnyHashable: Any]) -> String? { + for key in ["reason", "end_reason", "endReason"] { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed.lowercased() + } + } + } + return nil + } + + private func pruneCallTombstones(now: Date = Date()) { + tombstonedCallIds = tombstonedCallIds.filter { + now.timeIntervalSince($0.value) < callTombstoneTtl + } + } + + private func markCallTombstone(_ callId: String?) { + let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !normalized.isEmpty else { return } + pruneCallTombstones() + tombstonedCallIds[normalized] = Date() + DispatchQueue.main.async { [weak self] in + self?.voipDiagnosticsChannel?.invokeMethod( + "tombstoneCallId", + arguments: ["id": normalized] + ) + } + } + + private func isCallTombstoned(_ callId: String?) -> Bool { + let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !normalized.isEmpty else { return false } + pruneCallTombstones() + return tombstonedCallIds[normalized] != nil + } + private func voipMessageId(_ value: Any?) -> UInt64? { if let number = value as? NSNumber { let parsed = number.uint64Value @@ -236,6 +326,21 @@ import flutter_foreground_task return nil } + private func extraMarksEndedPush(_ extra: [String: Any]) -> Bool { + let value = extra["endedPush"] + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.intValue != 0 + } + if let text = value as? String { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "true" || normalized == "1" + } + return false + } + private func voipBool(_ value: Any?) -> Bool? { if let bool = value as? Bool { return bool @@ -356,10 +461,15 @@ import flutter_foreground_task var components = URLComponents() components.scheme = appUrlScheme components.host = "video-call" - components.queryItems = [ + var queryItems = [ URLQueryItem(name: "callKitId", value: call.data.uuid), URLQueryItem(name: "conversationUuid", value: conversationUuid) ] + let serverCallId = extraServerCallId(call.data.extra) + if !serverCallId.isEmpty { + queryItems.append(URLQueryItem(name: "call_id", value: serverCallId)) + } + components.queryItems = queryItems guard let url = components.url else { recordVoipDiagnostic( @@ -512,16 +622,31 @@ import flutter_foreground_task } } - /// An ended VoIP push has no message id, so its CallKit id is the - /// conversation uuid, not the ringing call. End that conversation only. - /// The first endCall while the push flag is set closes the call this push - /// just reported; the next ones close the call that is actually ringing. - private func endCallsForEndedPush(reportedId: String, conversationUuid: String) { + /// Ends the ringing CallKit call for an ended VoIP push. + /// + /// A push that carries `call_id` closes that call. A ring shown from the + /// open-app socket used to have only a derived id and an empty `call_id`. + /// When nothing matches the server id, those rings still close by + /// conversation. A ring that already stores a different `call_id` stays up. + private func endCallsForEndedPush( + reportedId: String, + conversationUuid: String, + serverCallId: String? = nil, + reason: String? = nil + ) { guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { + endReportedCallWithoutPlugin(reportedId) releaseIncomingCallClaim(reportedId) return } let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedServerCallId = serverCallId? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasServerCallId = !normalizedServerCallId.isEmpty + if hasServerCallId { + markCallTombstone(normalizedServerCallId) + } + var ids: [String] = [] func append(_ id: String) { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) @@ -532,19 +657,48 @@ import flutter_foreground_task ids.append(normalized) } append(reportedId) + var matchedServerCall = false for call in plugin.activeCalls() { let callId = (call["id"] as? String) ?? "" let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) + let extraCallId = extraServerCallId(call["extra"]) + if hasServerCallId { + let matchesServerCall = + callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame + || extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame + if matchesServerCall { + append(callId) + matchedServerCall = true + } + continue + } let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame - let sameConversation = !conversation.isEmpty && ( - handle.caseInsensitiveCompare(conversation) == .orderedSame - || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame + let matchesConversation = sameConversation( + handle: handle, + extraUuid: extraUuid, + conversation: conversation ) - if sameId || sameConversation { + if sameId || matchesConversation { append(callId) } } + if hasServerCallId && !matchedServerCall { + for call in plugin.activeCalls() { + let callId = (call["id"] as? String) ?? "" + let extraCallId = extraServerCallId(call["extra"]) + guard extraCallId.isEmpty else { continue } + let handle = (call["handle"] as? String) ?? "" + let extraUuid = extraConversationUuid(call["extra"]) + if sameConversation( + handle: handle, + extraUuid: extraUuid, + conversation: conversation + ) { + append(callId) + } + } + } for id in ids { let endData = flutter_callkit_incoming.Data( id: id, @@ -558,23 +712,29 @@ import flutter_foreground_task if !conversation.isEmpty { endExtra["conversationUuid"] = conversation } + if hasServerCallId { + endExtra["call_id"] = normalizedServerCallId + } + if let reason, !reason.isEmpty { + endExtra["reason"] = reason + } endData.extra = endExtra as NSDictionary + markRemoteEndedCallKitId(id) plugin.endCall(endData) releaseIncomingCallClaim(id) } } - private func activeCallId(forConversation conversationUuid: String) -> String? { - let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) - guard !conversation.isEmpty else { return nil } + private func activeCallId(matchingServerCallId serverCallId: String) -> String? { + let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] for call in calls { let callId = (call["id"] as? String) ?? "" guard !callId.isEmpty else { continue } - let handle = (call["handle"] as? String) ?? "" - let extraUuid = extraConversationUuid(call["extra"]) - if handle.caseInsensitiveCompare(conversation) == .orderedSame - || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame + let extraCallId = extraServerCallId(call["extra"]) + if callId.caseInsensitiveCompare(normalized) == .orderedSame + || extraCallId.caseInsensitiveCompare(normalized) == .orderedSame { return callId } @@ -582,21 +742,74 @@ import flutter_foreground_task return nil } - private func extraConversationUuid(_ extra: Any?) -> String { - let map: [String: Any] + private func activeCallId( + forConversation conversationUuid: String, + onlyWithoutServerCallId: Bool = false + ) -> String? { + let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + guard !conversation.isEmpty else { return nil } + let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + for call in calls { + let callId = (call["id"] as? String) ?? "" + guard !callId.isEmpty else { continue } + if onlyWithoutServerCallId && !extraServerCallId(call["extra"]).isEmpty { + continue + } + let handle = (call["handle"] as? String) ?? "" + let extraUuid = extraConversationUuid(call["extra"]) + if sameConversation(handle: handle, extraUuid: extraUuid, conversation: conversation) { + return callId + } + } + return nil + } + + private func sameConversation(handle: String, extraUuid: String, conversation: String) -> Bool { + guard !conversation.isEmpty else { return false } + return handle.caseInsensitiveCompare(conversation) == .orderedSame + || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame + } + + private func markRemoteEndedCallKitId(_ id: String) { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return } + remoteEndedCallLock.lock() + remoteEndedCallKitIds.insert(normalized) + remoteEndedCallLock.unlock() + } + + private func consumeRemoteEndedCallKitId(_ id: String) -> Bool { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + remoteEndedCallLock.lock() + defer { remoteEndedCallLock.unlock() } + return remoteEndedCallKitIds.remove(normalized) != nil + } + + private func endReportedCallWithoutPlugin(_ id: String) { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard let uuid = UUID(uuidString: normalized) else { return } + voipCallReporter.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) + } + + private func extraMap(_ extra: Any?) -> [String: Any] { if let typed = extra as? [String: Any] { - map = typed - } else if let object = extra as? NSDictionary { + return typed + } + if let object = extra as? NSDictionary { var converted: [String: Any] = [:] for (key, value) in object { if let key = key as? String { converted[key] = value } } - map = converted - } else { - return "" + return converted } + return [:] + } + + private func extraConversationUuid(_ extra: Any?) -> String { + let map = extraMap(extra) for key in ["conversationUuid", "conversation_uuid"] { if let value = map[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -608,6 +821,19 @@ import flutter_foreground_task return "" } + private func extraServerCallId(_ extra: Any?) -> String { + let map = extraMap(extra) + for key in ["call_id", "callId"] { + if let value = map[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return "" + } + func pushRegistry( _ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, @@ -648,11 +874,16 @@ import flutter_foreground_task .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) }) + let serverCallId = voipServerCallId(from: dict) + let endReason = voipEndReason(from: dict) if isEndedPush { + markCallTombstone(serverCallId) recordVoipDiagnostic( "voip_end_or_cancel_received", data: [ "markers": pushMarkers, + "call_id": serverCallId ?? "", + "reason": endReason ?? "", "payload": dict, ] ) @@ -678,23 +909,38 @@ import flutter_foreground_task ?? "" let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let parsedMessageId = voipMessageId(dict["message_id"]) + let tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId) let id: String let callKitIdSourceName: String - if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) { + if isEndedPush, let serverCallId, + let existing = activeCallId(matchingServerCallId: serverCallId) + { + id = existing + callKitIdSourceName = "active_call_id" + } else if isEndedPush, serverCallId?.isEmpty == false, + let existing = activeCallId( + forConversation: trimmedConversationUuid, + onlyWithoutServerCallId: true + ) + { + // The open app reported this ring from the chat socket before `call_id` + // was stored. End that ring instead of a new phantom call. + id = existing + callKitIdSourceName = "active_legacy_ring" + } else if isEndedPush, serverCallId == nil, + let existing = activeCallId(forConversation: trimmedConversationUuid) + { + // Legacy ended push without call_id. id = existing callKitIdSourceName = "active_conversation" - } else if let derived = derivedCallKitId( - conversationUuid: trimmedConversationUuid, - messageId: parsedMessageId - ) { - id = derived - callKitIdSourceName = "conversation_and_message" - } else if UUID(uuidString: trimmedConversationUuid) != nil { - id = trimmedConversationUuid.lowercased() - callKitIdSourceName = "conversation" } else { - id = UUID().uuidString.lowercased() - callKitIdSourceName = "generated" + let resolved = resolveCallKitId( + serverCallId: serverCallId, + conversationUuid: trimmedConversationUuid, + messageId: parsedMessageId + ) + id = resolved.id + callKitIdSourceName = resolved.source } let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) @@ -704,6 +950,12 @@ import flutter_foreground_task if !trimmedConversationUuid.isEmpty { extra["conversationUuid"] = trimmedConversationUuid } + if let serverCallId, !serverCallId.isEmpty { + extra["call_id"] = serverCallId + } + if let endReason, !endReason.isEmpty { + extra["reason"] = endReason + } if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String ?? dict["avatarUrl"] as? String ?? dict["avatar_url"] as? String @@ -714,6 +966,8 @@ import flutter_foreground_task } if let messageId = dict["message_id"] as? String { extra["messageId"] = messageId + } else if let messageId = parsedMessageId { + extra["messageId"] = String(messageId) } if let roomName = dict["room_name"] as? String { extra["roomName"] = roomName @@ -736,12 +990,17 @@ import flutter_foreground_task // Closing this report looks like a user decline. Dart must not tell the // backend, or the server sends another ended VoIP and the other phone // shows it as a new incoming call. - if isEndedPush { + if isEndedPush || tombstonedIncoming { extra["endedPush"] = true } // An ended push with no ringing call still has to be reported. That // report is closed at once, so it must not leave a trace anywhere. - let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation" + // Tombstoned late incoming is treated the same way. + let matchedActiveRing = + callKitIdSourceName == "active_call_id" + || callKitIdSourceName == "active_conversation" + || callKitIdSourceName == "active_legacy_ring" + let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming let data = flutter_callkit_incoming.Data( id: id, @@ -756,7 +1015,7 @@ import flutter_foreground_task data.extra = extra as NSDictionary data.missedNotificationSubtitle = "Пропущенный видеозвонок" data.missedNotificationCallbackText = "Перезвонить" - data.isShowMissedCallNotification = !isEndedPush + data.isShowMissedCallNotification = !(isEndedPush || tombstonedIncoming) data.includesCallsInRecents = !isPhantomEndedReport // The plugin activates AVAudioSession itself on answer and again 1.2s // later. That drops the ringing UI and leaves one side silent. CallKit @@ -772,13 +1031,16 @@ import flutter_foreground_task _ = reserveIncomingCall(id) NSLog( - "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" + "[EatvioVoIP] report incoming call id=\(id) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" ) recordVoipDiagnostic( "voip_incoming_received", data: [ "callKitId": id, "callKitIdSource": callKitIdSourceName, + "call_id": serverCallId ?? "", + "reason": endReason ?? "", + "tombstoned": tombstonedIncoming, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, @@ -792,7 +1054,9 @@ import flutter_foreground_task callKitId: id, callKitIdSourceName: callKitIdSourceName, conversationUuid: conversationUuid, - endAfterReport: isEndedPush, + serverCallId: serverCallId, + reason: endReason, + endAfterReport: isEndedPush || tombstonedIncoming, endImmediately: isPhantomEndedReport, completion: completion ) @@ -810,6 +1074,8 @@ import flutter_foreground_task callKitId: String, callKitIdSourceName: String, conversationUuid: String, + serverCallId: String? = nil, + reason: String? = nil, endAfterReport: Bool, endImmediately: Bool, completion: @escaping () -> Void @@ -827,32 +1093,44 @@ import flutter_foreground_task // Reason 6 is CXCallEndedReason.remoteEnded in the plugin. plugin.saveEndCall(callKitId, 6) } - if endAfterReport { - DispatchQueue.main.async { + let complete = { + if endAfterReport { self.endCallsForEndedPush( reportedId: callKitId, - conversationUuid: conversationUuid + conversationUuid: conversationUuid, + serverCallId: serverCallId, + reason: reason ) } + if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { + let activeCalls = plugin.activeCalls() + let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + self.recordVoipDiagnostic( + "callkit_report_completed", + data: [ + "callKitId": callKitId, + "callKitIdSource": callKitIdSourceName, + "call_id": serverCallId ?? "", + "reason": reason ?? "", + "conversationUuid": conversationUuid, + "activeCallIds": activeIds, + "activeCallsCount": activeCalls.count, + "endAfterReport": endAfterReport, + ] + ) + } + finish() } - if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { - let activeCalls = plugin.activeCalls() - let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") - self.recordVoipDiagnostic( - "callkit_report_completed", - data: [ - "callKitId": callKitId, - "callKitIdSource": callKitIdSourceName, - "conversationUuid": conversationUuid, - "activeCallIds": activeIds, - "activeCallsCount": activeCalls.count, - "endAfterReport": endAfterReport, - ] - ) + if Thread.isMainThread { + complete() + } else { + DispatchQueue.main.async(execute: complete) } - finish() } + if !endAfterReport { + prepareCallAudioCategory() + } if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport) return @@ -870,6 +1148,9 @@ import flutter_foreground_task finish() return } + if !endAfterReport { + prepareCallAudioCategory() + } voipCallReporter.report( uuid: uuid, callerName: data.nameCaller, @@ -888,8 +1169,13 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { - resumeAudioAfterCallKitEnds = true - prepareCallAudioCategory() + // CallKit stays up for the whole incoming call. Do not take the session + // back after a later deactivate — that path is hang-up. + resumeAudioAfterCallKitEnds = false + callKitOwnsAudioSession = true + // Category only. RTCAudioSession.setConfiguration here races CallKit's + // activation and can start I/O before didActivate. + prepareAvAudioSessionCategory() storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", @@ -910,24 +1196,61 @@ import flutter_foreground_task func onDecline(_ call: Call, _ action: CXEndCallAction) { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 + callKitOwnsAudioSession = false + // POST /decline from this callback. The Flutter event is only local + // cleanup and does not run when the user never opens the app. + let extra = call.data.extra as? [String: Any] ?? [:] + let markedRemoteEnd = consumeRemoteEndedCallKitId(call.data.uuid) + let closedByEndedPush = extraMarksEndedPush(extra) || markedRemoteEnd + if !closedByEndedPush { + VideoCallDeclineClient.send(extra: extra, handle: call.data.handle) + recordVoipDiagnostic( + "callkit_decline_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + ] + ) + voipDiagnosticsChannel?.invokeMethod( + "declinedVoipCall", + arguments: call.data.toJSON() + ) + } action.fulfill() } func onEnd(_ call: Call, _ action: CXEndCallAction) { + // The in-app call is already leaving, or the user hung up from CallKit. + // Do not restart the audio unit after this deactivation. + resumeAudioAfterCallKitEnds = false + audioRestartGeneration += 1 + callKitOwnsAudioSession = false action.fulfill() } func onTimeOut(_ call: Call) {} func didActivateAudioSession(_ audioSession: AVAudioSession) { + callKitOwnsAudioSession = true let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) - applyCallAudioConfiguration(active: false) + // WebRTC must start I/O inside this CallKit callback. Enabling later + // misses the session CallKit just activated; toggling after that + // does not restart the ADM, so the incoming microphone stays silent. rtcSession.isAudioEnabled = true + // setCallConnected requests a second CXAnswerCallAction. That fires + // didActivate again after the microphone track already exists and can + // stop AudioEngine. Dart starts recording on each activation once the + // track is published. The first activation (accept, no track yet) is a + // no-op on the Dart side. + DispatchQueue.main.async { [weak self] in + self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil) + } } func didDeactivateAudioSession(_ audioSession: AVAudioSession) { + callKitOwnsAudioSession = false let rtcSession = RTCAudioSession.sharedInstance() rtcSession.audioSessionDidDeactivate(audioSession) // The audio unit is stopped, but isAudioEnabled stays true. A later @@ -943,6 +1266,7 @@ import flutter_foreground_task func providerDidReset() { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 + callKitOwnsAudioSession = false } private func configureWebRTCAudioSession() { @@ -950,33 +1274,62 @@ import flutter_foreground_task RTCAudioSessionConfiguration.setWebRTC(configuration) } + private func prepareAvAudioSessionCategory() { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory( + .playAndRecord, + mode: .videoChat, + options: [.allowBluetooth, .defaultToSpeaker] + ) + } catch { + NSLog("[EatvioVoIP] prepare call audio category failed: \(error)") + } + } + private func prepareCallAudioCategory() { + prepareAvAudioSessionCategory() applyCallAudioConfiguration(active: false) } private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true + if callKitOwnsAudioSession { + rtcSession.isAudioEnabled = true + return + } applyCallAudioConfiguration(active: true) - // Starting the audio unit while the configuration lock is held deadlocks - // the capture thread, so the mic stays silent. rtcSession.isAudioEnabled = true } - /// Restarts capture after the previous audio graph has disconnected. - /// Toggling the unit in the same turn makes voice processing throw. + /// Outgoing (no CallKit) may need a teardown to attach capture. Incoming + /// already started the unit in didActivate — toggling it here kills ADM. private func restartInAppCallAudioUnit() { - audioRestartGeneration += 1 - let generation = audioRestartGeneration let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true + if callKitOwnsAudioSession { + rtcSession.isAudioEnabled = true + return + } + audioRestartGeneration += 1 + let generation = audioRestartGeneration rtcSession.isAudioEnabled = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, generation == self.audioRestartGeneration else { return } - self.activateInAppCallAudioSession() + self.enableInAppCallAudioUnit() } } + private func enableInAppCallAudioUnit() { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true + if !callKitOwnsAudioSession { + applyCallAudioConfiguration(active: true) + } + rtcSession.isAudioEnabled = true + } + private func applyCallAudioConfiguration(active: Bool) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() @@ -993,6 +1346,10 @@ import flutter_foreground_task audioRestartGeneration += 1 let rtcSession = RTCAudioSession.sharedInstance() rtcSession.isAudioEnabled = false + // Hang-up stops the unit first, then Dart closes CallKit. A second + // setActive(false) while CallKit still owns the session fails or + // tears the graph down in the wrong order. + if callKitOwnsAudioSession { return } rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { diff --git a/ios/Runner/VideoCallDeclineClient.swift b/ios/Runner/VideoCallDeclineClient.swift new file mode 100644 index 00000000..fb24ce50 --- /dev/null +++ b/ios/Runner/VideoCallDeclineClient.swift @@ -0,0 +1,149 @@ +import Foundation +import UIKit + +enum VideoCallDeclineClient { + private static let uuidPattern = + #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"# + + /// `POST /video-call/decline` from the CallKit callback. The process is + /// awake here even when the user never opens the app afterwards. + static func send(extra: [String: Any], handle: String) { + let fields = declineFields(extra: extra, handle: handle) + guard let request = makeRequest( + baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"), + token: prefsString("token"), + hwId: UIDevice.current.identifierForVendor?.uuidString, + fields: fields + ) else { + NSLog( + "[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" + ) + return + } + + let task = EatvioBackgroundTask() + task.begin(name: "video-call-decline") + let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + NSLog( + "[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" + ) + task.end() + } + urlTask.resume() + } + + static func makeRequest( + baseUrl: String?, + token: String?, + hwId: String?, + fields: VideoCallDeclineFields + ) -> URLRequest? { + if fields.endedPush { return nil } + let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + while root.hasSuffix("/") { + root.removeLast() + } + guard !conversation.isEmpty, !authToken.isEmpty else { return nil } + guard deviceId.count >= 8, deviceId.count <= 64 else { return nil } + guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil } + guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") + else { return nil } + + var body: [String: String] = ["hw_id": deviceId] + if let callId = fields.callId { + body["call_id"] = callId + } + guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 10 + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = data + return request + } + + private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields { + let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"]) + ?? handle.trimmingCharacters(in: .whitespacesAndNewlines) + let rawCallId = firstString(extra, keys: ["call_id", "callId"]) + return VideoCallDeclineFields( + conversationUuid: conversation, + callId: backendCallId(rawCallId, conversation: conversation), + endedPush: marksEndedPush(extra["endedPush"]) + ) + } + + private static func backendCallId(_ callId: String?, conversation: String) -> String? { + let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let regex = try? NSRegularExpression(pattern: uuidPattern), + regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil + else { return nil } + if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame { + return nil + } + return id + } + + private static func firstString(_ extra: [String: Any], keys: [String]) -> String? { + for key in keys { + guard let value = extra[key] as? String else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return nil + } + + private static func marksEndedPush(_ value: Any?) -> Bool { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.intValue != 0 } + if let text = value as? String { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "true" || normalized == "1" + } + return false + } + + private static func prefsString(_ key: String) -> String? { + let value = UserDefaults.standard.string(forKey: "flutter.\(key)")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, !value.isEmpty { return value } + return nil + } +} + +struct VideoCallDeclineFields { + var conversationUuid: String + var callId: String? + var endedPush: Bool +} + +private final class EatvioBackgroundTask { + private let lock = NSLock() + private var id: UIBackgroundTaskIdentifier = .invalid + + func begin(name: String) { + let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + self?.end() + } + lock.lock() + id = taskId + lock.unlock() + } + + func end() { + lock.lock() + let current = id + id = .invalid + lock.unlock() + guard current != .invalid else { return } + UIApplication.shared.endBackgroundTask(current) + } +} diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index 289d3d7b..921adaf5 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -48,6 +48,7 @@ class AppConfig { return; } unawaited(sl().setString(PrefsKeys.customBaseUrl, normalized)); + unawaited(sl().setString(PrefsKeys.apiBaseUrl, normalized)); } /// Список глобальных BlocProvider'ов diff --git a/lib/core/cached_base_url.dart b/lib/core/cached_base_url.dart index d6e82e73..1888d9f6 100644 --- a/lib/core/cached_base_url.dart +++ b/lib/core/cached_base_url.dart @@ -1,16 +1,30 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs_keys.dart'; /// Подставляет сохранённый base URL до создания HTTP-клиента. +/// +/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный +/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен. void applyCachedBaseUrl() { if (!sl.isRegistered() || !sl.isRegistered()) { return; } final cached = sl().getString(PrefsKeys.customBaseUrl)?.trim(); - if (cached == null || cached.isEmpty) { + if (cached != null && cached.isNotEmpty) { + sl().setBaseUrl(cached); return; } - sl().setBaseUrl(cached); + _persistApiBaseUrl(sl().baseUrl); +} + +void _persistApiBaseUrl(String baseUrl) { + final normalized = baseUrl.trim(); + if (normalized.isEmpty || !sl.isRegistered()) { + return; + } + unawaited(sl().setString(PrefsKeys.apiBaseUrl, normalized)); } diff --git a/lib/core/device/device_hw_id_provider.dart b/lib/core/device/device_hw_id_provider.dart index 383aac2b..b94b88b9 100644 --- a/lib/core/device/device_hw_id_provider.dart +++ b/lib/core/device/device_hw_id_provider.dart @@ -1,16 +1,70 @@ import 'dart:io'; +import 'dart:math'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +/// Stable id of this install, sent as `hw_id` on push registration and on +/// video-call accept/decline. +/// +/// Android `Build.ID` is the firmware id. Two phones on the same build share +/// it, so the backend treats them as one device and a decline on one does not +/// reach the other. A per-install id stays unique. abstract final class DeviceHwIdProvider { - static Future current() async { - final deviceInfo = DeviceInfoPlugin(); + static const androidInstallIdKey = 'eatvio.android_install_hw_id'; + static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated'; + + static Future current({SharedPreferences? preferences}) async { if (Platform.isIOS) { - return (await deviceInfo.iosInfo).identifierForVendor; + return (await DeviceInfoPlugin().iosInfo).identifierForVendor; } if (Platform.isAndroid) { - return (await deviceInfo.androidInfo).id; + return androidInstallId(preferences: preferences); } return null; } + + static Future androidInstallId({ + SharedPreferences? preferences, + }) async { + final prefs = preferences ?? await SharedPreferences.getInstance(); + final existing = prefs.getString(androidInstallIdKey)?.trim(); + if (existing != null && existing.length >= 8) return existing; + + final created = _newInstallId(); + await prefs.setString(androidInstallIdKey, created); + return created; + } + + /// The previous Android registration used `Build.ID`. Drop that token once + /// and register again with the install id. + static Future androidTokenNeedsReregister({ + SharedPreferences? preferences, + }) async { + if (!Platform.isAndroid) return false; + final prefs = preferences ?? await SharedPreferences.getInstance(); + return prefs.getBool(androidTokenMigratedKey) != true; + } + + static Future markAndroidTokenReregistered({ + SharedPreferences? preferences, + }) async { + final prefs = preferences ?? await SharedPreferences.getInstance(); + await prefs.setBool(androidTokenMigratedKey, true); + } + + static String _newInstallId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex.substring(0, 8)}-' + '${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } } diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index d18024b3..19c7cbc7 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; import 'package:eatvio_app/core/notifications/notification_permission_requester.dart'; import 'package:eatvio_app/core/push/hms_client_error.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -44,15 +45,16 @@ class FirebaseApi { Future registerAnonymousPushToken() async { try { await _requestNotificationPermission(); - final deviceInfo = DeviceInfoPlugin(); final credentials = await _resolvePushCredentials(); final token = credentials.token; if (token == null || token.isEmpty) return; + final hwId = await DeviceHwIdProvider.current(); + if (hwId == null || hwId.length < 8) return; await sl().post( '/api/anonymous_token', data: { 'platform': credentials.platform, - 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, + 'hw_id': hwId, 'token': token, }, ).timeout( @@ -72,23 +74,38 @@ class FirebaseApi { if (!await TokenStorage.haveTokenInStorage()) return; try { await _requestNotificationPermission(); - final deviceInfo = DeviceInfoPlugin(); final credentials = await _resolvePushCredentials(); final token = credentials.token; - if (token != null && token.isNotEmpty) { - await sl().post( - '/api/profile/notification/token/register', - data: { - 'platform': credentials.platform, - 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, - 'token': token, - }, - ).timeout( - const Duration(seconds: 10), - onTimeout: () { - throw TimeoutException('registerPushToken timeout'); - }, - ); + final hwId = await DeviceHwIdProvider.current(); + final reregisterAndroid = + await DeviceHwIdProvider.androidTokenNeedsReregister(); + if (token != null && + token.isNotEmpty && + hwId != null && + hwId.length >= 8) { + if (reregisterAndroid) { + try { + await unregisterPushToken(); + } catch (_) {} + } + await sl() + .post( + '/api/profile/notification/token/register', + data: { + 'platform': credentials.platform, + 'hw_id': hwId, + 'token': token, + }, + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('registerPushToken timeout'); + }, + ); + if (reregisterAndroid) { + await DeviceHwIdProvider.markAndroidTokenReregistered(); + } } } catch (e, st) { log(e.toString(), stackTrace: st); @@ -145,12 +162,13 @@ class FirebaseApi { final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim(); if (token == null || token.isEmpty) return; - final deviceInfo = DeviceInfoPlugin(); + final hwId = await DeviceHwIdProvider.current(); + if (hwId == null || hwId.length < 8) return; await sl().post( '/api/profile/notification/token/voip/register', data: { 'platform': 'ios', - 'hw_id': (await deviceInfo.iosInfo).identifierForVendor, + 'hw_id': hwId, 'token': token, }, ).timeout( diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index b6481d6d..189dfc82 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -62,12 +62,16 @@ class PushNavigationHandler { final endedPayload = VideoCallEndedPushPayload.tryParse(data); if (endedPayload != null) { developer.log( - 'PushNavigationHandler: dismiss incoming call UI, source=$source', + 'PushNavigationHandler: dismiss incoming call UI, source=$source ' + 'call_id=${endedPayload.callId ?? '-'} ' + 'reason=${endedPayload.reason?.name ?? '-'}', name: 'PushNavigationHandler', ); unawaited( VideoCallEndedPushHandler.dismissIncomingCallUi( conversationUuid: endedPayload.conversationUuid, + callId: endedPayload.callId, + reason: endedPayload.reason, ), ); return; diff --git a/lib/core/prefs_keys.dart b/lib/core/prefs_keys.dart index df74fa84..e0fbc272 100644 --- a/lib/core/prefs_keys.dart +++ b/lib/core/prefs_keys.dart @@ -5,4 +5,5 @@ enum PrefsKeys { marketingBottomSheetHasBeenShown, isEmailVerificationSkipped, customBaseUrl, + apiBaseUrl, } diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 2d79ad63..3ba31b8a 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -18,7 +18,7 @@ class WsManager { final ErrorReporter _errorReporter; WsManager({required this.wsService, required ErrorReporter errorReporter}) - : _errorReporter = errorReporter; + : _errorReporter = errorReporter; PusherChannelsClient? client; int? userId; @@ -43,14 +43,32 @@ class WsManager { /// Контроллер создаётся один раз и не закрывается в [clear]: подписчики /// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать /// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize]. - StreamController _reconnectController = StreamController.broadcast(); + StreamController _reconnectController = + StreamController.broadcast(); Stream? _directChatStream; Stream? _clientTypingStream; - Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); + Stream? get newMessagesStream => + _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); - Stream? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); + /// The callee rejected the ring. Broadcast to the caller and the callee. + Stream? get videoCallDeclinedStream => + _privateMessagesChannel?.bind('video-call-declined'); + + /// The call was cancelled or hung up. Same channel the web client uses. + Stream? get videoCallEndedStream => + _privateMessagesChannel?.bind('video-call-ended'); + + /// Another device accepted the ring. Ringing on this device must stop, + /// except on the device that accepted. + Stream? get videoCallAcceptedStream => + _privateMessagesChannel?.bind('video-call-accepted'); + + Stream? get newNotificationsStream => + _privateNotificationChannel?.bind( + 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', + ); Stream? get directChatStream => _directChatStream; @@ -86,15 +104,9 @@ class WsManager { connectionErrorHandler: (exception, trace, refresh) { refresh(); }, - minimumReconnectDelayDuration: const Duration( - seconds: 1, - ), - defaultActivityDuration: const Duration( - seconds: 30, - ), - waitForPongDuration: const Duration( - seconds: 30, - ), + minimumReconnectDelayDuration: const Duration(seconds: 1), + defaultActivityDuration: const Duration(seconds: 30), + waitForPongDuration: const Duration(seconds: 30), ); String? extractSocketId(String? data) { @@ -107,7 +119,9 @@ class WsManager { } } - _connectionEstablishedSubscription = client?.eventStream.listen((event) async { + _connectionEstablishedSubscription = client?.eventStream.listen(( + event, + ) async { if (event.name == 'pusher:connection_established') { final data = event.data; final socketId = extractSocketId(data); @@ -124,7 +138,8 @@ class WsManager { await _initPrivateMessagesChannel(); _lifecycleSubscription = client?.lifecycleStream.listen((data) { - if (data == PusherChannelsClientLifeCycleState.establishedConnection) { + if (data == + PusherChannelsClientLifeCycleState.establishedConnection) { handleConnectionEstablished(); } }); @@ -166,12 +181,13 @@ class WsManager { final token = await TokenStorage.getTokenFromStorage(); _privateNotificationChannel = client?.privateChannel( '$newNotificationsChannelName.$userId', - authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( - authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), ); } @@ -179,12 +195,13 @@ class WsManager { final token = await TokenStorage.getTokenFromStorage(); _privateMessagesChannel = client?.privateChannel( '$newMessagesChannelName.$userId', - authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( - authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), ); } @@ -193,14 +210,17 @@ class WsManager { final token = await TokenStorage.getTokenFromStorage(); _privateDirectChatChannel = client?.privateChannel( '$directChatChannelName.$chatId', - authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( - authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), + ); + _directChatStream = _privateDirectChatChannel?.bind( + 'App\\Events\\ChatMessageUpdated', ); - _directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); _clientTypingStream = _privateDirectChatChannel?.bind('client-typing'); } @@ -208,12 +228,13 @@ class WsManager { final token = await TokenStorage.getTokenFromStorage(); _presenceDirectChatChannel = client?.presenceChannel( '$presenceDirectChatChannelName.$chatId', - authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( - authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), ); } @@ -240,7 +261,8 @@ class WsManager { void Function(bool isOnline) onCounterpartOnlineChanged, ) { _presenceDirectChatListeners.removeWhere( - (listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, + (listener) => + listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, ); if (_presenceDirectChatListeners.isEmpty) { unsubscribePresenceDirectChatChannel(); @@ -254,21 +276,26 @@ class WsManager { _presenceChannelEventsBound = true; - _presenceSubscriptionSucceededSubscription = - channel.whenSubscriptionSucceeded().listen((_) { - final counterpartIds = _presenceDirectChatListeners - .map((listener) => listener.counterpartUserId) - .toSet(); - for (final counterpartUserId in counterpartIds) { - _emitCounterpartPresence(counterpartUserId); - } - }); - _presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) { + _presenceSubscriptionSucceededSubscription = channel + .whenSubscriptionSucceeded() + .listen((_) { + final counterpartIds = _presenceDirectChatListeners + .map((listener) => listener.counterpartUserId) + .toSet(); + for (final counterpartUserId in counterpartIds) { + _emitCounterpartPresence(counterpartUserId); + } + }); + _presenceMemberAddedSubscription = channel.whenMemberAdded().listen(( + event, + ) { final memberUserId = _parsePresenceMemberUserId(event.data); if (memberUserId == null) return; _notifyPresenceListeners(memberUserId, isOnline: true); }); - _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) { + _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen(( + event, + ) { final memberUserId = _parsePresenceMemberUserId(event.data); if (memberUserId == null) return; _notifyPresenceListeners(memberUserId, isOnline: false); @@ -280,11 +307,15 @@ class WsManager { if (channel == null) return; final members = channel.state?.members; if (members == null) return; - final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null; + final isOnline = + members.getMemberInfo(counterpartUserId.toString()) != null; _notifyPresenceListeners(counterpartUserId, isOnline: isOnline); } - void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) { + void _notifyPresenceListeners( + int counterpartUserId, { + required bool isOnline, + }) { for (final listener in _presenceDirectChatListeners) { if (listener.counterpartUserId == counterpartUserId) { listener.onCounterpartOnlineChanged(isOnline); diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index f9f72353..499766f6 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; import 'package:eatvio_app/core/enums/base_state.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; @@ -10,7 +11,9 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_sta import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -23,11 +26,19 @@ class ChatsCubit extends Cubit { StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newLastMessageSubscription; StreamSubscription? reconnectedSubscription; + StreamSubscription? videoCallDeclinedSubscription; + StreamSubscription? videoCallEndedSubscription; + StreamSubscription? videoCallAcceptedSubscription; final Set _presentedVideoCallMessageIds = {}; /// Test seam. Production uses CallKit. Future Function(VideoCallPushPayload payload)? presentIncomingVideoCall; + + /// Test seam. Production reads [DeviceHwIdProvider.current]. + Future Function()? currentDeviceHwId; Future Function(String? conversationUuid)? dismissIncomingVideoCall; + Future Function({String? conversationUuid, String? callId})? + endActiveVideoCall; ChatsCubit({ required this.chatRepository, @@ -55,6 +66,37 @@ class ChatsCubit extends Cubit { _handleNewMessageViaSocket(event.data); }); + await videoCallDeclinedSubscription?.cancel(); + videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen( + (event) { + unawaited( + _closeCallFromSocket( + VideoCallSocketSignal.tryParseDeclined(event.data), + ), + ); + }, + ); + + await videoCallEndedSubscription?.cancel(); + videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen(( + event, + ) { + unawaited( + _closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)), + ); + }); + + await videoCallAcceptedSubscription?.cancel(); + videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen( + (event) { + unawaited( + _closeCallFromSocket( + VideoCallSocketSignal.tryParseEnded(event.data), + ), + ); + }, + ); + await reconnectedSubscription?.cancel(); reconnectedSubscription = wsManager.reconnected.listen((_) { unawaited(getChatRooms()); @@ -170,6 +212,7 @@ class ChatsCubit extends Cubit { try { final json = jsonDecode(data) as Map; final message = ChatMessageModel.fromJson(json); + final callId = _stringField(json, const ['call_id', 'callId']); final currentUserId = wsManager.userId; final isOwn = currentUserId != null && message.authorId == currentUserId; @@ -217,6 +260,7 @@ class ChatsCubit extends Cubit { message, isOwn: isOwn, chatRooms: currentChatRooms, + callId: callId, ); } catch (e, st) { await reportFrom(e, st, this); @@ -227,12 +271,15 @@ class ChatsCubit extends Cubit { ChatMessageModel message, { required bool isOwn, required List chatRooms, + String? callId, }) { final kind = VideoCallChatSignal.kindOf(message); if (kind == VideoCallChatSignalKind.ended) { unawaited(_dismissIncomingVideoCallUi(message.roomId)); unawaited( - VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId), + VideoCallEndedPushHandler.endActiveCallIfMatches( + conversationUuid: message.roomId, + ), ); return; } @@ -246,6 +293,7 @@ class ChatsCubit extends Cubit { message: message, callerName: caller?.name, callerAvatarUrl: caller?.profilePhotoUrl, + callId: callId, ); if (payload == null) return; unawaited(_showIncomingVideoCall(payload)); @@ -260,6 +308,35 @@ class ChatsCubit extends Cubit { } } + Future _closeCallFromSocket(VideoCallSocketSignal? signal) async { + if (signal == null) return; + if (await _acceptedOnThisDevice(signal)) return; + if (signal.endsActiveCall) { + final endActive = endActiveVideoCall; + if (endActive != null) { + await endActive( + conversationUuid: signal.conversationUuid, + callId: signal.callId, + ); + } else { + await VideoCallEndedPushHandler.endActiveCallIfMatches( + conversationUuid: signal.conversationUuid, + callId: signal.callId, + ); + } + } + final dismiss = dismissIncomingVideoCall; + if (dismiss != null) { + await dismiss(signal.conversationUuid); + return; + } + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: signal.conversationUuid, + callId: signal.callId, + reason: signal.reason, + ); + } + Future _dismissIncomingVideoCallUi(String? conversationUuid) async { try { final dismiss = dismissIncomingVideoCall; @@ -278,6 +355,34 @@ class ChatsCubit extends Cubit { } } + /// `video-call-accepted` also reaches the device that just answered. + /// Closing CallKit there hangs up the call that is starting. + Future _acceptedOnThisDevice(VideoCallSocketSignal signal) async { + if (signal.reason != VideoCallEndReason.accepted) return false; + final acceptedId = signal.acceptedDeviceId?.trim(); + if (acceptedId == null || acceptedId.isEmpty) return false; + try { + final current = + (await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim(); + if (current == null || current.isEmpty) return false; + return current.toLowerCase() == acceptedId.toLowerCase(); + } catch (e, st) { + log( + 'ChatsCubit: failed to read hw id for accepted call: $e', + stackTrace: st, + ); + return false; + } + } + + String? _stringField(Map json, List keys) { + for (final key in keys) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + } + return null; + } + ChatUser? _callerFromRooms( ChatMessageModel message, List chatRooms, @@ -306,6 +411,9 @@ class ChatsCubit extends Cubit { @override Future close() { newIncomingMessagesSubscription?.cancel(); + videoCallDeclinedSubscription?.cancel(); + videoCallEndedSubscription?.cancel(); + videoCallAcceptedSubscription?.cancel(); reconnectedSubscription?.cancel(); return super.close(); } diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 67017dd0..84d78dbb 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -162,17 +162,18 @@ class MenuScreen extends StatelessWidget { actions: [ CupertinoDialogAction( onPressed: () async { + try { + // Logout revokes the Sanctum token. Unregister first, or + // FCM / VoIP tokens stay on the server and calls still ring. + await sl().unregisterPushTokens(); + } catch (e, s) { + await sl().report(e, s, origin: 'MenuScreen'); + } try { await sl().post('${sl().baseUrl}/api/profile/logout'); } catch (e, s) { await sl().report(e, s, origin: 'MenuScreen'); } finally { - try { - // Needs Authorization header → must run before removing token. - await sl().unregisterPushTokens(); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } await sl().endCall(); await sl().clear(); await TokenStorage.removeTokenInStorage(); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index c7bdd5a5..ff814360 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_foreground_c import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; @@ -18,6 +19,7 @@ import 'package:eatvio_app/features/video_calls/repositories/video_call_reposito import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:livekit_client/livekit_client.dart' as lk; import 'package:permission_handler/permission_handler.dart'; @@ -32,12 +34,15 @@ class VideoCallManager { VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, + Future Function()? resolveHwId, }) : _videoCallRepository = videoCallRepository, _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = - audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() { + audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(), + _resolveHwId = resolveHwId ?? DeviceHwIdProvider.current { + _audioSession.onIosAudioSessionActivated = _restartIosCaptureIfMicOn; FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData); if (Platform.isIOS) { // LiveKit rewrites the audio session whenever a remote audio track @@ -52,6 +57,7 @@ class VideoCallManager { final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; + final Future Function() _resolveHwId; static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions( @@ -78,6 +84,7 @@ class VideoCallManager { bool _callEndedNotified = true; bool _forceEndRequested = false; bool _sessionMayRequestToken = false; + String? _activeCallId; final Map _leftConversationAt = {}; final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; @@ -102,6 +109,14 @@ class VideoCallManager { bool get callEndedNotified => _callEndedNotified; + String? get activeCallId => _activeCallId; + + void rememberCallId(String? callId) { + final id = callId?.trim(); + if (id == null || id.isEmpty) return; + _activeCallId = id; + } + bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && @@ -235,7 +250,23 @@ class VideoCallManager { } } - await endCall(); + final previousUuid = _snapshot.conversationUuid; + final hasLiveRoom = _room != null && !_liveKitDisposed; + if (shouldPreserveCallKitWhenStartingCall( + previousConversationUuid: previousUuid, + nextConversationUuid: conversationUuid, + )) { + // The accepted CallKit call owns the iOS microphone. A full endCall + // here deactivates that session before the mic track exists. + if (hasLiveRoom) { + _disposeRoomEphemerals(); + await _disconnectLiveKitWithTimeout(); + _liveKitDisposed = false; + _callStageEntered = false; + } + } else { + await endCall(); + } _sessionMayRequestToken = true; _resetSessionFlagsForNewRoom(); @@ -430,7 +461,6 @@ class VideoCallManager { final shouldNotifyBackend = shouldNotifyCallEnded( alreadyNotified: _callEndedNotified, hasConversation: uuid.isNotEmpty, - peerStillInRoom: _roomHasRemoteUser(), ); if (uuid.isNotEmpty) { _callEndedNotified = true; @@ -438,30 +468,42 @@ class VideoCallManager { } _sessionMayRequestToken = false; _disposeRoomEphemerals(); - await _disconnectLiveKitOnly(); + await _disconnectLiveKitWithTimeout(); if (shouldNotifyBackend) { try { - await _videoCallRepository.notifyCallEnded(uuid); + await _videoCallRepository.notifyCallEnded( + uuid, + callId: backendVideoCallId( + callId: _activeCallId, + conversationUuid: uuid, + ), + ); } catch (_) {} } await _foreground.stopOngoingCall(); await _stopCallAudioSession(); + await _dismissIosCallKitAfterHangup(uuid); _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; _liveKitDisposed = true; _myProfileNameFuture = null; + _activeCallId = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } /// Notifies backend that this device accepted an incoming call so ringing /// stops on the user's other devices. - Future notifyIncomingCallAccepted(String conversationUuid) async { + Future notifyIncomingCallAccepted( + String conversationUuid, { + String? callId, + }) async { final uuid = conversationUuid.trim(); if (uuid.isEmpty) return; + rememberCallId(callId); if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; - final hwId = (await DeviceHwIdProvider.current())?.trim(); + final hwId = (await _resolveHwId())?.trim(); if (hwId == null || hwId.length < 8) return; _callAcceptNotifiedConversationUuids.add(uuid); @@ -469,35 +511,43 @@ class VideoCallManager { await _videoCallRepository.notifyCallAccepted( conversationUuid: uuid, hwId: hwId, + callId: _activeCallId ?? callId, ); } catch (_) { _callAcceptNotifiedConversationUuids.remove(uuid); } } - /// Decline from incoming UI before joining the LiveKit room. + /// Drops local call state after the system decline button. + /// + /// `POST /video-call/decline` is sent from iOS and Android at the tap, so + /// other devices stop ringing even when this isolate is not running. Future declineIncomingCall(String conversationUuid) async { final uuid = conversationUuid.trim(); - if (uuid.isEmpty) { - await endCall(); - return; - } + await _cleanupLocalCallAfterDecline( + conversationUuid: uuid.isEmpty ? null : uuid, + ); + } - if (_snapshot.conversationUuid == uuid) { - await endCall(); - return; + Future _cleanupLocalCallAfterDecline({String? conversationUuid}) async { + final uuid = conversationUuid?.trim(); + if (uuid != null && uuid.isNotEmpty) { + _leftConversationAt[uuid] = DateTime.now(); } - - await endCall(); - if (!incomingDeclineShouldNotifyBackend( - leftAt: _leftConversationAt[uuid], - now: DateTime.now(), - )) { - return; - } - try { - await _videoCallRepository.notifyCallEnded(uuid); - } catch (_) {} + _callEndedNotified = true; + _sessionMayRequestToken = false; + _disposeRoomEphemerals(); + await _disconnectLiveKitWithTimeout(); + await _foreground.stopOngoingCall(); + await _stopCallAudioSession(); + await _dismissIosCallKitAfterHangup(uuid); + _lifecycleBinding.detach(); + _callStageEntered = false; + _videoPausedForBackground = false; + _liveKitDisposed = true; + _myProfileNameFuture = null; + _activeCallId = null; + _emitSnapshot(VideoCallManagerSnapshot.initial); } /// [currentUiEnabled] reflects what the user sees on the mic control @@ -529,6 +579,7 @@ class VideoCallManager { audioCaptureOptions: _micCaptureOptions, ); if (next) { + await _bindIosCallKitAudioEngine(); await _audioSession.restartCapture(); } } catch (_) { @@ -728,6 +779,7 @@ class VideoCallManager { true, audioCaptureOptions: _micCaptureOptions, ); + await _bindIosCallKitAudioEngine(); await _audioSession.restartCapture(); } catch (_) {} } @@ -899,16 +951,17 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); }) - ..on((_) { + ..on((event) { final room = _room; if (room == null) return; + final departedIdentity = event.participant.identity; if (!shouldForceEndCallAfterPeerLeft( callStageEntered: _callStageEntered, sessionConnected: _snapshot.connectPhase == VideoCallConnectPhase.connected, - remoteIdentities: room.remoteParticipants.values.map( - (participant) => participant.identity, - ), + remoteIdentities: room.remoteParticipants.values + .map((participant) => participant.identity) + .where((identity) => identity != departedIdentity), )) { return; } @@ -1009,11 +1062,18 @@ class VideoCallManager { if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return; _emitSnapshot(_snapshot.copyWith(connectError: null)); try { - final tokenResponse = await _videoCallRepository.getParticipantToken( - _snapshot.conversationUuid, - ); + final tokenResponse = await _videoCallRepository + .getParticipantToken( + _snapshot.conversationUuid, + callId: backendVideoCallId( + callId: _activeCallId, + conversationUuid: _snapshot.conversationUuid, + ), + ) + .timeout(const Duration(seconds: 12)); if (_liveKitDisposed) return; + rememberCallId(tokenResponse.callId); _preparedWsUrl = tokenResponse.wsUrl; _preparedToken = tokenResponse.token; @@ -1056,17 +1116,24 @@ class VideoCallManager { var token = _preparedToken; if (wsUrl == null || token == null) { if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return; - final tokenResponse = await _videoCallRepository.getParticipantToken( - _snapshot.conversationUuid, - ); + final tokenResponse = await _videoCallRepository + .getParticipantToken( + _snapshot.conversationUuid, + callId: backendVideoCallId( + callId: _activeCallId, + conversationUuid: _snapshot.conversationUuid, + ), + ) + .timeout(const Duration(seconds: 12)); if (_liveKitDisposed) return; + rememberCallId(tokenResponse.callId); wsUrl = tokenResponse.wsUrl; token = tokenResponse.token; _preparedWsUrl = wsUrl; _preparedToken = token; } - await room.connect(wsUrl, token); + await room.connect(wsUrl, token).timeout(const Duration(seconds: 15)); if (_liveKitDisposed) return; await _ensureLocalParticipantNamePublished(); @@ -1077,6 +1144,13 @@ class VideoCallManager { ); } + Future _disconnectLiveKitWithTimeout() { + return _disconnectLiveKitOnly().timeout( + const Duration(seconds: 3), + onTimeout: () {}, + ); + } + Future _disconnectLiveKitOnly() async { if (_room == null) return; await _stopCallAudioSession(); @@ -1120,6 +1194,11 @@ class VideoCallManager { if (status.isGranted) return true; status = await Permission.microphone.request(); if (status.isGranted) return true; + if (Platform.isIOS) { + // CallKit already opened a recording session. A stale permission + // status must not block publishing the incoming microphone. + return true; + } final ctx2 = _navigatorContext; if (ctx2 == null || !ctx2.mounted) return false; final s = S.of(ctx2); @@ -1142,14 +1221,6 @@ class VideoCallManager { ); } - bool _roomHasRemoteUser() { - final room = _room; - if (room == null) return false; - return videoCallRoomStillHasRemoteUser( - room.remoteParticipants.values.map((participant) => participant.identity), - ); - } - bool get _canHandleCallAudio => _callStageEntered && !_liveKitDisposed && @@ -1187,10 +1258,13 @@ class VideoCallManager { if (micOn) { try { - await lp?.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); + await lp + ?.setMicrophoneEnabled( + true, + audioCaptureOptions: _micCaptureOptions, + ) + .timeout(const Duration(seconds: 8)); + await _bindIosCallKitAudioEngine(); await _audioSession.restartCapture(); } catch (_) { effectiveMic = false; @@ -1232,6 +1306,26 @@ class VideoCallManager { _lastMutedMicTotalEnergy = null; } + Future _bindIosCallKitAudioEngine() async { + if (!Platform.isIOS) return; + await _audioSession.startCallKitCapture(); + } + + /// CallKit activates the session on accept and again when the call is + /// marked connected. The second activation can stop AudioEngine after the + /// microphone track is already published, so recording has to start again. + Future _restartIosCaptureIfMicOn() async { + if (!_iosMicCaptureReady) return; + // The plugin posts an audio-interruption ended notice in the same + // didActivate. Starting immediately loses to that notice. + await Future.delayed(const Duration(milliseconds: 300)); + if (!_iosMicCaptureReady) return; + await _audioSession.startCallKitCapture(); + } + + bool get _iosMicCaptureReady => + Platform.isIOS && _snapshot.micEnabled && _callStageEntered; + Future _startCallAudioSession() async { if (!_canHandleCallAudio) return; try { @@ -1244,4 +1338,31 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } + + /// Incoming iOS keeps CallKit up for the whole call so the microphone + /// session stays. Hang-up has to close that CallKit call. + /// + /// An empty id used to match every active call, including the one the + /// callee just answered, and CallKit then deactivated the microphone. + Future _dismissIosCallKitAfterHangup(String? conversationUuid) async { + if (!Platform.isIOS) return; + final uuid = conversationUuid?.trim().toLowerCase() ?? ''; + if (uuid.isEmpty) return; + try { + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + for (final call in activeCalls) { + final handle = call.handle?.trim().toLowerCase(); + final extraUuid = + call.extra?['conversationUuid']?.toString().trim().toLowerCase() ?? + call.extra?['conversation_uuid']?.toString().trim().toLowerCase(); + final matches = handle == uuid || extraUuid == uuid; + if (!matches) continue; + VideoCallRemoteEndRegistry.mark(call.id); + try { + await FlutterCallkitIncoming.endCall(call.id); + } catch (_) {} + } + } catch (_) {} + } } diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart index f344822d..e176d040 100644 --- a/lib/features/video_calls/models/video_call_models.dart +++ b/lib/features/video_calls/models/video_call_models.dart @@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse { required String token, required String roomName, required String wsUrl, + @JsonKey(name: 'call_id') String? callId, + @JsonKey(name: 'call_status') String? callStatus, + @JsonKey(name: 'call_expires_at') String? callExpiresAt, }) = _VideoCallTokenResponse; factory VideoCallTokenResponse.fromJson(Map json) => @@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse { @freezed abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { - const factory VideoCallEndedResponse({required bool sent}) = - _VideoCallEndedResponse; + const factory VideoCallEndedResponse({ + required bool sent, + @JsonKey(name: 'call_id') String? callId, + String? status, + }) = _VideoCallEndedResponse; factory VideoCallEndedResponse.fromJson(Map json) => _$VideoCallEndedResponseFromJson(json); @@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse { const factory VideoCallAcceptResponse({ required bool sent, @JsonKey(name: 'devices_notified') required int devicesNotified, + @JsonKey(name: 'call_id') String? callId, + String? status, + @JsonKey(name: 'already_accepted') bool? alreadyAccepted, }) = _VideoCallAcceptResponse; factory VideoCallAcceptResponse.fromJson(Map json) => _$VideoCallAcceptResponseFromJson(json); } + +@freezed +abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse { + const factory VideoCallDeclineResponse({ + required bool sent, + @JsonKey(name: 'devices_notified') required int devicesNotified, + @JsonKey(name: 'call_id') String? callId, + String? status, + }) = _VideoCallDeclineResponse; + + factory VideoCallDeclineResponse.fromJson(Map json) => + _$VideoCallDeclineResponseFromJson(json); +} diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 8c575bee..adb57bb0 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; @@ -83,10 +84,19 @@ class VideoCallSessionCubit extends BaseCubit { } if (_isCallSessionInactive(snapshot)) { unawaited(_systemPipCoordinator.disarm()); - if (_callScreenRouteVisible) { + if (isTransientIdleSnapshotWhileCallScreenVisible( + callScreenVisible: _callScreenRouteVisible, + conversationUuid: snapshot.conversationUuid, + needsManualReconnect: snapshot.linkIssue.needsManualReconnect, + connectError: snapshot.connectError, + )) { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } + if (_callScreenRouteVisible) { + emit(_sessionStateFromSnapshot(snapshot)); + return; + } _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); @@ -246,7 +256,7 @@ class VideoCallSessionCubit extends BaseCubit { counterpartName: counterpartName, localParticipantName: localParticipantName, counterpartIsConsultant: counterpartIsConsultant ?? false, - micEnabled: false, + micEnabled: isIncomingAccepted, videoEnabled: false, callStageEntered: false, connectError: null, @@ -259,7 +269,7 @@ class VideoCallSessionCubit extends BaseCubit { conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, - micEnabled: false, + micEnabled: isIncomingAccepted, videoEnabled: false, ); if (!started) { @@ -361,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit { _autoEnterInFlight = true; try { await _requestMicBeforeIncomingEnter(); - await enterCallStage(); + for (var attempt = 0; attempt < 3; attempt++) { + if (isClosed || state.callStageEntered) return; + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + await enterCallStage(); + if (state.callStageEntered) return; + await Future.delayed(Duration(milliseconds: 350 * (attempt + 1))); + } } finally { _autoEnterInFlight = false; } @@ -371,10 +388,11 @@ class VideoCallSessionCubit extends BaseCubit { Future _requestMicBeforeIncomingEnter() async { if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; - final granted = await _requestMicPermissionForCall(); - if (!granted || isClosed) return; + await _requestMicPermissionForCall(); + if (isClosed) return; + if (state.isMicActiveForUi) return; - await _videoCallManager.toggleMic(state.isMicActiveForUi); + await _videoCallManager.toggleMic(false); } @visibleForTesting @@ -382,7 +400,14 @@ class VideoCallSessionCubit extends BaseCubit { Future _requestMicPermissionForCall() async { try { - final status = await Permission.microphone.request(); + final current = await Permission.microphone.status; + if (current.isGranted) return true; + // The incoming-call activity can keep this future pending when the app + // was already open. The call has to leave the loading screen anyway. + final status = await Permission.microphone.request().timeout( + const Duration(seconds: 3), + onTimeout: () => PermissionStatus.denied, + ); return status.isGranted; } catch (_) { return false; @@ -395,7 +420,8 @@ class VideoCallSessionCubit extends BaseCubit { if (_videoCallManager.callEndedNotified) return; final inCall = state.callStageEntered || - state.connectPhase == VideoCallConnectPhase.connected; + state.connectPhase == VideoCallConnectPhase.connected || + state.connectPhase == VideoCallConnectPhase.connecting; if (!inCall) return; _handlingRemoteCallEnd = true; diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart index 15cbb4f1..7d105451 100644 --- a/lib/features/video_calls/repositories/video_call_repository.dart +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -8,9 +8,13 @@ class VideoCallRepository { static const Map _emptyJsonBody = {}; - Future getParticipantToken(String conversationUuid) { + Future getParticipantToken( + String conversationUuid, { + String? callId, + }) { return videoCallService.getParticipantToken( conversationUuid: conversationUuid, + callId: callId, ); } @@ -18,20 +22,46 @@ class VideoCallRepository { return videoCallService.getAdminToken(conversationUuid: conversationUuid); } - Future notifyCallEnded(String conversationUuid) { + Future notifyCallEnded( + String conversationUuid, { + String? callId, + }) { + final body = { + if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(), + }; return videoCallService.notifyCallEnded( conversationUuid: conversationUuid, - body: _emptyJsonBody, + body: body.isEmpty ? _emptyJsonBody : body, ); } Future notifyCallAccepted({ required String conversationUuid, required String hwId, + String? callId, }) { return videoCallService.notifyCallAccepted( conversationUuid: conversationUuid, - body: {'hw_id': hwId}, + body: { + 'hw_id': hwId, + if (callId != null && callId.trim().isNotEmpty) + 'call_id': callId.trim(), + }, + ); + } + + Future notifyCallDeclined({ + required String conversationUuid, + required String hwId, + String? callId, + }) { + return videoCallService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: { + 'hw_id': hwId, + if (callId != null && callId.trim().isNotEmpty) + 'call_id': callId.trim(), + }, ); } } diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index e08bb8e8..910b7cad 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; typedef VideoCallAudioSessionFactory = Future Function(); typedef IosCallAudioSessionHandler = Future Function(String method); +typedef IosAdmHandler = Future Function(); /// Configures and owns the platform audio session for an active VoIP call. class VideoCallAudioSessionCoordinator { @@ -12,18 +13,38 @@ class VideoCallAudioSessionCoordinator { VideoCallAudioSessionFactory? audioSessionFactory, bool? isIos, IosCallAudioSessionHandler? iosCallAudioSession, + IosAdmHandler? stopLocalRecording, + IosAdmHandler? startLocalRecording, }) : _audioSessionFactory = audioSessionFactory ?? (() => AudioSession.instance), _isIos = isIos ?? Platform.isIOS, - _iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession; + _iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession, + _stopLocalRecording = stopLocalRecording ?? _invokeStopLocalRecording, + _startLocalRecording = + startLocalRecording ?? _invokeStartLocalRecording { + if (iosCallAudioSession == null && _isIos) { + _activationListener = this; + _iosChannel.setMethodCallHandler(_onIosAudioSessionCall); + } + } static const MethodChannel _iosChannel = MethodChannel( 'com.eatvioapp/call_audio_session', ); + static const MethodChannel _webrtcChannel = MethodChannel( + 'FlutterWebRTC.Method', + ); final VideoCallAudioSessionFactory _audioSessionFactory; final bool _isIos; final IosCallAudioSessionHandler _iosCallAudioSession; + final IosAdmHandler _stopLocalRecording; + final IosAdmHandler _startLocalRecording; + static VideoCallAudioSessionCoordinator? _activationListener; + + /// Called when CallKit activates the audio session, including the second + /// activation from `setCallConnected`. + Future Function()? onIosAudioSessionActivated; static final AudioSessionConfiguration callConfiguration = AudioSessionConfiguration( @@ -54,6 +75,36 @@ class VideoCallAudioSessionCoordinator { return _iosChannel.invokeMethod(method); } + static Future _onIosAudioSessionCall(MethodCall call) async { + if (call.method != 'audioSessionActivated') return; + await _activationListener?.onIosAudioSessionActivated?.call(); + } + + static Future _ensureWebRtcInitialized() async { + try { + await _webrtcChannel.invokeMethod( + 'initialize', + {'options': {}}, + ); + } catch (_) {} + } + + static Future _invokeStopLocalRecording() async { + await _ensureWebRtcInitialized(); + await _webrtcChannel.invokeMethod( + 'stopLocalRecording', + {}, + ); + } + + static Future _invokeStartLocalRecording() async { + await _ensureWebRtcInitialized(); + await _webrtcChannel.invokeMethod( + 'startLocalRecording', + {}, + ); + } + Future start() async { if (_isIos) { await _iosCallAudioSession('activate'); @@ -66,7 +117,7 @@ class VideoCallAudioSessionCoordinator { await session.configure(callConfiguration); _configured = true; } - await session.setActive(true); + await session.setActive(true).timeout(const Duration(seconds: 5)); } Future reactivate() async { @@ -97,9 +148,24 @@ class VideoCallAudioSessionCoordinator { } catch (_) {} } + /// flutter-webrtc 1.2 uses AudioEngine ADM. `isAudioEnabled` does not start + /// that engine. `initAndStartRecording` has to run after the microphone + /// track exists. Do not stop first: that tears the capture graph down and + /// the published track stays silent. + Future startCallKitCapture() async { + if (!_isIos) return; + try { + await _startLocalRecording(); + _iosActive = true; + } catch (_) {} + } + Future stop() async { if (_isIos) { _iosActive = false; + try { + await _stopLocalRecording(); + } catch (_) {} try { await _iosCallAudioSession('deactivate'); } catch (_) {} diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index f57b6745..5d9b456a 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler. import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; @@ -36,6 +37,7 @@ class VideoCallCallKitCoordinator { final Map _callKitParamsById = {}; final Set _acceptNavigationCompletedIds = {}; final Map> _acceptDeliveriesInFlight = {}; + final Map> _acceptDeliveriesByConversation = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; @@ -77,6 +79,10 @@ class VideoCallCallKitCoordinator { _scheduleNativeVoipDiagnosticsDrain(); case 'acceptedVoipCall': await _handleNativeAcceptedVoipCall(call.arguments); + case 'declinedVoipCall': + await _handleNativeDeclinedVoipCall(call.arguments); + case 'tombstoneCallId': + _handleNativeTombstoneCallId(call.arguments); } }); await _firebaseApi.registerPushToken(); @@ -104,12 +110,19 @@ class VideoCallCallKitCoordinator { } Future _onAndroidCallPushMethodCall(MethodCall call) async { - if (call.method == 'onCallKitLaunchIntent') { - log( - 'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native', - name: 'VideoCallCallKit', - ); - await _handleAndroidCallKitLaunchIntent(call.arguments); + switch (call.method) { + case 'onCallKitLaunchIntent': + log( + 'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitLaunchIntent(call.arguments); + case 'onCallKitDecline': + log( + 'VideoCallCallKitCoordinator: received onCallKitDecline from native', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitDecline(call.arguments); } } @@ -223,6 +236,21 @@ class VideoCallCallKitCoordinator { ); } + Future _handleAndroidCallKitDecline(Object? arguments) async { + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = + _parseString(data, const ['id', 'uuid']) ?? + _parseString(extra, const ['conversationUuid', 'conversation_uuid']); + if (callKitId == null) return; + + try { + await _androidCallPushChannel.invokeMethod('drainPendingCallKitDecline'); + } catch (_) {} + + await _handleDecline(callKitId, eventExtra: extra); + } + void dispose() { final observer = _androidResumeObserver; if (observer != null) { @@ -364,7 +392,11 @@ class VideoCallCallKitCoordinator { ); return; } - await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra); + await _handleDecline( + id, + eventExtra: _callKitEventParams(event)?.extra, + skipBackendNotify: Platform.isIOS && event is CallEventActionCallDecline, + ); } Future _handleEndedEvent(CallEvent event) async { @@ -490,8 +522,9 @@ class VideoCallCallKitCoordinator { return; } + final callId = _parseCallId(extra); await _deliverIncomingAccept( - callKitId: conversationUuid, + callKitId: callId ?? conversationUuid, conversationUuid: conversationUuid, counterpartName: _parseString(extra, const [ 'counterpartName', @@ -517,6 +550,17 @@ class VideoCallCallKitCoordinator { ); } + void _handleNativeTombstoneCallId(Object? arguments) { + final data = _stringKeyedMap(arguments); + final callId = _parseString(data, const ['id', 'call_id', 'callId']); + if (callId == null || callId.isEmpty) return; + VideoCallTombstoneRegistry.mark(callId); + log( + 'VideoCallCallKitCoordinator: native tombstone call_id=$callId', + name: 'VideoCallCallKit', + ); + } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { if (!Platform.isIOS) return; @@ -543,6 +587,9 @@ class VideoCallCallKitCoordinator { return; } + final callId = _parseCallId(extra) ?? callKitId; + _manager.rememberCallId(callId); + await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, @@ -574,6 +621,39 @@ class VideoCallCallKitCoordinator { ); } + /// Local cleanup after iOS CallKit decline. The backend request is already + /// sent from AppDelegate, including when this isolate is suspended. + Future _handleNativeDeclinedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + if (callKitExtraMarksEndedPush(Map.from(extra))) return; + + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId != null && + (_suppressedEndCallKitIds.contains(callKitId) || + VideoCallRemoteEndRegistry.contains(callKitId))) { + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + final callId = backendVideoCallId( + callId: + _parseCallId(extra) ?? + (callKitId != null && callKitId.contains('-') ? callKitId : null), + conversationUuid: conversationUuid, + ); + log( + 'VideoCallCallKitCoordinator: native declinedVoipCall conversation=$conversationUuid callId=$callId', + name: 'VideoCallCallKit', + ); + await _manager.declineIncomingCall(conversationUuid); + } + Future _handleAccept(String callKitId) async { if (_acceptNavigationCompletedIds.contains(callKitId)) return; @@ -655,6 +735,13 @@ class VideoCallCallKitCoordinator { name: 'VideoCallCallKit', ); + final conversationKey = conversationUuid.trim().toLowerCase(); + final conversationDelivery = _acceptDeliveriesByConversation[conversationKey]; + if (conversationDelivery != null) { + await conversationDelivery; + return; + } + _rememberPendingIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, @@ -681,12 +768,16 @@ class VideoCallCallKitCoordinator { counterpartIsConsultant: counterpartIsConsultant, ); _acceptDeliveriesInFlight[callKitId] = delivery; + _acceptDeliveriesByConversation[conversationKey] = delivery; try { await delivery; } finally { if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) { _acceptDeliveriesInFlight.remove(callKitId); } + if (identical(_acceptDeliveriesByConversation[conversationKey], delivery)) { + _acceptDeliveriesByConversation.remove(conversationKey); + } } } @@ -700,10 +791,26 @@ class VideoCallCallKitCoordinator { }) async { if (_acceptNavigationCompletedIds.contains(callKitId)) return; - _suppressedEndCallKitIds.add(callKitId); - unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + final params = _paramsForCallId(callKitId); + final callId = + _parseCallId(params?.extra ?? const {}) ?? + (callKitId.contains('-') ? callKitId : null); + if (callId != null) { + _manager.rememberCallId(callId); + } + unawaited( + _manager.notifyIncomingCallAccepted(conversationUuid, callId: callId), + ); + + // Android accept starts an ongoing-call service that holds the mic. + // Joining LiveKit while that service is still up never leaves the + // loading screen when the app was already in the foreground. + _suppressCallEnd(callKitId); + _suppressCallEnd(conversationUuid); + if (callId != null) _suppressCallEnd(callId); try { + await _finishCallKitAfterAccept(callKitId); await _waitForAcceptNavigationReady(); final navigated = await _openConnectingScreenAfterAccept( @@ -739,8 +846,6 @@ class VideoCallCallKitCoordinator { ); unawaited(_drainPendingIncomingAcceptWithRetries()); } - - await FlutterCallkitIncoming.endCall(callKitId); } catch (error, stackTrace) { log( 'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error', @@ -748,12 +853,22 @@ class VideoCallCallKitCoordinator { stackTrace: stackTrace, ); } finally { - unawaited( - Future.delayed(const Duration(seconds: 2), () { - _suppressedEndCallKitIds.remove(callKitId); - _acceptNavigationCompletedIds.remove(callKitId); - }), - ); + // Android ends CallKit right after accept, so the plugin ENDED event + // must be ignored briefly. iOS keeps CallKit for the whole call: a + // later ENDED is a real hang-up and must not be swallowed. + if (Platform.isAndroid) { + final suppressedIds = { + callKitId, + conversationUuid, + ?callId, + }; + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _suppressedEndCallKitIds.removeAll(suppressedIds); + _acceptNavigationCompletedIds.remove(callKitId); + }), + ); + } } } @@ -785,6 +900,12 @@ class VideoCallCallKitCoordinator { return; } + _suppressCallEnd(pending.callKitId); + _suppressCallEnd(pending.conversationUuid); + try { + await _finishCallKitAfterAccept(pending.callKitId); + } catch (_) {} + await _waitForAcceptNavigationReady(); await _settleUiAfterResume(); @@ -807,9 +928,44 @@ class VideoCallCallKitCoordinator { ); } catch (_) {} } - try { - await FlutterCallkitIncoming.endCall(pending.callKitId); - } catch (_) {} + } + + /// After answer: Android dismisses the incoming notification. iOS keeps the + /// CallKit call so the audio session stays, and marks it connected so the + /// plugin stops treating every later `endCall` as the last PushKit report. + Future _finishCallKitAfterAccept(String callKitId) async { + if (Platform.isIOS) { + try { + await FlutterCallkitIncoming.setCallConnected(callKitId); + } catch (_) {} + return; + } + _suppressCallEnd(callKitId); + await FlutterCallkitIncoming.endCall(callKitId); + if (!Platform.isAndroid) return; + await _waitUntilAndroidCallKitReleased(); + } + + /// Telecom keeps the microphone while the incoming connection is active. + /// Joining LiveKit before it is gone never leaves the loading screen when + /// the app was already in the foreground. + Future _waitUntilAndroidCallKitReleased() async { + final deadline = DateTime.now().add(const Duration(milliseconds: 1500)); + while (DateTime.now().isBefore(deadline)) { + try { + final calls = await FlutterCallkitIncoming.activeCalls(); + if (calls.isEmpty) return; + } catch (_) { + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } + + void _suppressCallEnd(String id) { + final trimmed = id.trim(); + if (trimmed.isEmpty) return; + _suppressedEndCallKitIds.add(trimmed); } Future _settleUiAfterResume() async { @@ -859,6 +1015,7 @@ class VideoCallCallKitCoordinator { Future _handleDecline( String callKitId, { Map? eventExtra, + bool skipBackendNotify = false, }) async { if (_suppressedEndCallKitIds.contains(callKitId)) { log( @@ -893,6 +1050,13 @@ class VideoCallCallKitCoordinator { return; } + if (skipBackendNotify) { + // AppDelegate already POSTed /decline. This plugin event must not send + // /ended for the same tap. + _forgetCallKitId(callKitId); + return; + } + _suppressedEndCallKitIds.add(callKitId); try { await FlutterCallkitIncoming.endCall(callKitId); @@ -908,6 +1072,7 @@ class VideoCallCallKitCoordinator { if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.endCall(); + VideoCallNotificationNavigator.closeCallRouteIfOpen(); return; } @@ -932,19 +1097,35 @@ class VideoCallCallKitCoordinator { // iOS reports an ended VoIP push to CallKit and closes it right away. // That close is marked and must be remembered before the decline (or the // later timeout) event for the same id arrives with no marker. - final remoteEnd = callKitDeclineIsEndedPush( - cachedExtra: _paramsForCallId(callKitId)?.extra, - eventExtra: eventExtra, - ); + final remoteEnd = + callKitDeclineIsEndedPush( + cachedExtra: _paramsForCallId(callKitId)?.extra, + eventExtra: eventExtra, + ) || + VideoCallRemoteEndRegistry.contains(callKitId); if (remoteEnd) { VideoCallRemoteEndRegistry.mark(callKitId); } _forgetCallKitId(callKitId); - // Native already ends a remote-ended call. Calling endCall again here - // overwrites the plugin's stored payload, so the decline that follows - // would lose the marker. + final inAppCall = + _manager.isActiveCall || + _cubit.state.connectPhase != VideoCallConnectPhase.idle; + + if (inAppCall) { + if (remoteEnd) { + await _cubit.handleRemoteCallEnded(); + } else { + await _cubit.endCall(); + VideoCallNotificationNavigator.closeCallRouteIfOpen(); + } + return; + } + + // Native already ends a remote-ended ringing call. Calling endCall + // again here overwrites the plugin's stored payload, so the decline + // that follows would lose the marker. if (remoteEnd) return; try { @@ -954,6 +1135,7 @@ class VideoCallCallKitCoordinator { void _forgetCallKitId(String callKitId) { _callKitParamsById.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); if (_activeCallKitId == callKitId) { _activeCallKitId = null; } @@ -977,6 +1159,10 @@ class VideoCallCallKitCoordinator { return ''; } + static String? _parseCallId(Map extra) { + return _parseString(extra, const ['call_id', 'callId']); + } + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; diff --git a/lib/features/video_calls/services/video_call_chat_signal.dart b/lib/features/video_calls/services/video_call_chat_signal.dart index 7e0f22b3..8327ae97 100644 --- a/lib/features/video_calls/services/video_call_chat_signal.dart +++ b/lib/features/video_calls/services/video_call_chat_signal.dart @@ -57,6 +57,7 @@ abstract final class VideoCallChatSignal { required ChatMessageModel message, String? callerName, String? callerAvatarUrl, + String? callId, }) { if (kindOf(message) != VideoCallChatSignalKind.started) return null; final conversationUuid = message.roomId?.trim(); @@ -64,6 +65,7 @@ abstract final class VideoCallChatSignal { final resolvedName = callerName?.trim(); final avatar = callerAvatarUrl?.trim(); + final serverCallId = callId?.trim(); return VideoCallPushPayload( conversationUuid: conversationUuid, counterpartName: resolvedName != null && resolvedName.isNotEmpty @@ -71,6 +73,9 @@ abstract final class VideoCallChatSignal { : callerNameFromBody(message.data), counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null, messageId: message.id > 0 ? message.id : null, + callId: serverCallId != null && serverCallId.isNotEmpty + ? serverCallId + : null, ); } diff --git a/lib/features/video_calls/services/video_call_end_reason.dart b/lib/features/video_calls/services/video_call_end_reason.dart new file mode 100644 index 00000000..4f962a3c --- /dev/null +++ b/lib/features/video_calls/services/video_call_end_reason.dart @@ -0,0 +1,35 @@ +/// Cause of a `video_call_ended` push / Echo event. +/// +/// This is not the session status. It tells the client how to close ringing +/// or an active call. +enum VideoCallEndReason { + accepted, + declined, + cancelled, + ended, + timeout; + + static VideoCallEndReason? tryParse(Object? raw) { + if (raw == null) return null; + final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_'); + if (normalized.isEmpty) return null; + for (final value in VideoCallEndReason.values) { + if (value.name == normalized) return value; + } + if (normalized == 'hangup' || normalized == 'hang_up') { + return VideoCallEndReason.ended; + } + return null; + } + + /// Close ringing UI; do not tear down an already-accepted LiveKit call. + bool get dismissesRingingOnly => + this == VideoCallEndReason.accepted || + this == VideoCallEndReason.declined || + this == VideoCallEndReason.cancelled || + this == VideoCallEndReason.timeout; + + /// Tear down an accepted in-progress call. + bool get endsActiveCall => + this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout; +} diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 9f50cc39..8a6c6191 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -1,8 +1,11 @@ +import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -13,30 +16,125 @@ abstract final class VideoCallEndedPushHandler { 'com.eatvioapp/video_call_push', ); - static Future dismissIncomingCallUi({String? conversationUuid}) async { - await endActiveCallIfMatches(conversationUuid); + static Future dismissIncomingCallUi({ + String? conversationUuid, + String? callId, + VideoCallEndReason? reason, + }) async { + final normalizedCallId = callId?.trim(); + if (normalizedCallId != null && normalizedCallId.isNotEmpty) { + VideoCallTombstoneRegistry.mark(normalizedCallId); + } + + log( + 'VideoCallEndedPushHandler: dismiss ' + 'call_id=${normalizedCallId ?? '-'} ' + 'conversation=${conversationUuid ?? '-'} ' + 'reason=${reason?.name ?? '-'}', + name: 'VideoCallEndedPush', + ); + + final shouldEndActive = reason == null || reason.endsActiveCall; + if (shouldEndActive && reason != VideoCallEndReason.accepted) { + await endActiveCallIfMatches( + conversationUuid: conversationUuid, + callId: normalizedCallId, + ); + } + if (Platform.isAndroid) { try { - await _androidPushChannel.invokeMethod('dismissIncomingCalls'); + await _androidPushChannel.invokeMethod( + 'dismissIncomingCalls', + { + if (normalizedCallId != null && normalizedCallId.isNotEmpty) + 'call_id': normalizedCallId, + if (conversationUuid != null && conversationUuid.trim().isNotEmpty) + 'conversation_uuid': conversationUuid.trim(), + }, + ); return; } catch (_) {} } - await _dismissViaCallKit(conversationUuid); + await _dismissViaCallKit( + conversationUuid: conversationUuid, + callId: normalizedCallId, + ); } - static Future endActiveCallIfMatches(String? conversationUuid) async { + /// Closes the CallKit / incoming UI without ending the LiveKit session. + /// Used when this client hangs up: CallKit stays up for the whole incoming + /// call on iOS and must be dismissed with the in-app hang-up. + static Future dismissCallKitOnly({ + String? conversationUuid, + String? callId, + }) { + return _dismissViaCallKit( + conversationUuid: conversationUuid, + callId: callId, + ); + } + + static Future endActiveCallIfMatches({ + String? conversationUuid, + String? callId, + }) async { final uuid = conversationUuid?.trim(); if (uuid == null || uuid.isEmpty) return; if (!sl.isRegistered()) return; final cubit = sl(); if (cubit.state.conversationUuid != uuid) return; + // callId is reserved for ringing dismiss matching; active sessions are + // still keyed by conversation until the cubit stores call_id. try { await cubit.handleRemoteCallEnded(); } catch (_) {} } + static bool callMatches({ + required CallKitParams call, + String? conversationUuid, + String? callId, + }) { + final normalizedCallId = callId?.trim().toLowerCase(); + if (normalizedCallId != null && normalizedCallId.isNotEmpty) { + final id = call.id.trim().toLowerCase(); + if (id.isNotEmpty && id == normalizedCallId) return true; + final extra = call.extra; + if (extra != null) { + for (final key in const ['call_id', 'callId']) { + final value = extra[key]; + if (value is String && + value.trim().toLowerCase() == normalizedCallId) { + return true; + } + } + } + // A ring that already has its own call id must not be closed by a + // stale ended event for another call in the same chat. A legacy ring + // whose id is only the conversation still has to close. + if (_callHasDistinctCallId(call)) return false; + } + + final uuid = conversationUuid?.trim().toLowerCase(); + if (uuid == null || uuid.isEmpty) return false; + return callMatchesConversation(call, uuid); + } + + /// True when this ring already carries a server call id. Conversation + /// fallback would close a newer call in the same chat. + static bool _callHasDistinctCallId(CallKitParams call) { + final extra = call.extra; + if (extra == null) return false; + for (final key in const ['call_id', 'callId']) { + final value = extra[key]; + if (value is String && value.trim().isNotEmpty) return true; + } + return false; + } + static bool callMatchesConversation( CallKitParams call, String conversationUuid, @@ -57,11 +155,18 @@ abstract final class VideoCallEndedPushHandler { /// The close of a ringing call comes back from the plugin as a decline. /// Each id is marked first, so the coordinator does not report that /// decline to the backend as if the user had rejected the call. - static Future _dismissViaCallKit(String? conversationUuid) async { + static Future _dismissViaCallKit({ + String? conversationUuid, + String? callId, + }) async { final uuid = conversationUuid?.trim(); + final normalizedCallId = callId?.trim(); try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (uuid == null || uuid.isEmpty) { + final hasTarget = + (normalizedCallId != null && normalizedCallId.isNotEmpty) || + (uuid != null && uuid.isNotEmpty); + if (!hasTarget) { for (final call in activeCalls) { VideoCallRemoteEndRegistry.mark(call.id); } @@ -69,7 +174,13 @@ abstract final class VideoCallEndedPushHandler { return; } for (final call in activeCalls) { - if (!callMatchesConversation(call, uuid)) continue; + if (!callMatches( + call: call, + conversationUuid: uuid, + callId: normalizedCallId, + )) { + continue; + } VideoCallRemoteEndRegistry.mark(call.id); try { await FlutterCallkitIncoming.endCall(call.id); diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart index 4d65a705..6767a70c 100644 --- a/lib/features/video_calls/services/video_call_ended_push_payload.dart +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -1,23 +1,58 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; /// Marker for a data-only `video_call_ended` push. class VideoCallEndedPushPayload { - const VideoCallEndedPushPayload({this.conversationUuid}); + const VideoCallEndedPushPayload({ + this.conversationUuid, + this.callId, + this.reason, + this.acceptedDeviceId, + this.acceptedClient, + }); final String? conversationUuid; + final String? callId; + final VideoCallEndReason? reason; + final String? acceptedDeviceId; + final String? acceptedClient; static VideoCallEndedPushPayload? tryParse(Map data) { if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; - final rawUuid = - data['conversationUuid'] ?? - data['conversation_uuid'] ?? - data['uuid']; - final conversationUuid = rawUuid?.toString().trim(); + + final conversationUuid = _string(data, const [ + 'conversationUuid', + 'conversation_uuid', + 'uuid', + ]); + final callId = _string(data, const ['call_id', 'callId']); + final acceptedDeviceId = _string(data, const [ + 'accepted_device_id', + 'acceptedDeviceId', + ]); + final acceptedClient = _string(data, const [ + 'accepted_client', + 'acceptedClient', + ]); + return VideoCallEndedPushPayload( - conversationUuid: - conversationUuid == null || conversationUuid.isEmpty - ? null - : conversationUuid, + conversationUuid: conversationUuid, + callId: callId, + reason: VideoCallEndReason.tryParse( + data['reason'] ?? data['end_reason'] ?? data['endReason'], + ), + acceptedDeviceId: acceptedDeviceId, + acceptedClient: acceptedClient, ); } + + static String? _string(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + return null; + } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 1b11f693..d7e3a5a5 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -58,12 +58,16 @@ abstract final class VideoCallFcmHandler { } log( - 'VideoCallFcmHandler: dismiss incoming call UI, source=$source', + 'VideoCallFcmHandler: dismiss incoming call UI, source=$source ' + 'call_id=${endedPayload.callId ?? '-'} ' + 'reason=${endedPayload.reason?.name ?? '-'}', name: 'VideoCallFcm', ); try { await VideoCallEndedPushHandler.dismissIncomingCallUi( conversationUuid: endedPayload.conversationUuid, + callId: endedPayload.callId, + reason: endedPayload.reason, ); } catch (e, st) { log( diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 1751ebc8..0d377fba 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -1,7 +1,9 @@ +import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -53,17 +55,23 @@ abstract final class VideoCallIncomingPushPresenter { ? payload.counterpartName!.trim() : 'Итвио'; final ios = isIos ?? Platform.isIOS; + if (VideoCallTombstoneRegistry.contains(payload.callId)) { + log( + 'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}', + name: 'VideoCallIncoming', + ); + return; + } if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) { await (showAndroidIncoming ?? _showAndroidIncoming)(payload); return; } - final callId = ios - ? VideoCallKitId.iosUuid( - conversationUuid: payload.conversationUuid, - messageId: payload.messageId, - ) ?? - payload.conversationUuid - : payload.conversationUuid; + final callId = VideoCallKitId.resolve( + callId: payload.callId, + conversationUuid: payload.conversationUuid, + messageId: payload.messageId, + isIos: ios, + ); if (ios) { final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed; @@ -91,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter { duration: 45000, extra: { 'conversationUuid': payload.conversationUuid, + if (payload.callId != null) 'call_id': payload.callId, if (payload.counterpartName != null) 'counterpartName': payload.counterpartName, if (payload.counterpartAvatarUrl != null) @@ -99,6 +108,7 @@ abstract final class VideoCallIncomingPushPresenter { 'localParticipantName': payload.localParticipantName, if (payload.counterpartIsConsultant != null) 'counterpartIsConsultant': payload.counterpartIsConsultant, + if (payload.messageId != null) 'message_id': payload.messageId, }, callingNotification: const NotificationParams(showNotification: false), missedCallNotification: const NotificationParams( @@ -138,6 +148,7 @@ abstract final class VideoCallIncomingPushPresenter { await _androidPushChannel .invokeMethod('showIncomingCall', { 'conversation_uuid': payload.conversationUuid, + if (payload.callId != null) 'call_id': payload.callId!, if (payload.counterpartName != null) 'caller_name': payload.counterpartName!, if (payload.counterpartAvatarUrl != null) @@ -150,6 +161,8 @@ abstract final class VideoCallIncomingPushPresenter { : 'false', if (payload.messageId != null) 'message_id': payload.messageId.toString(), + if (payload.expiresAt != null) + 'expires_at': payload.expiresAt!.toUtc().toIso8601String(), }); } catch (_) {} } diff --git a/lib/features/video_calls/services/video_call_kit_id.dart b/lib/features/video_calls/services/video_call_kit_id.dart index 16759108..61a11939 100644 --- a/lib/features/video_calls/services/video_call_kit_id.dart +++ b/lib/features/video_calls/services/video_call_kit_id.dart @@ -1,14 +1,33 @@ -/// CallKit on iOS rejects a second incoming call that reuses a UUID. +/// Resolves the CallKit / ConnectionService id for an incoming call. /// -/// The conversation uuid stays in the call payload for navigation. The CallKit -/// id is derived from that uuid plus the chat message id, so one ring is one -/// call and the next ring in the same chat still appears. Android notifications -/// keep the conversation uuid: the same id updates one incoming notification. +/// Prefer the backend `call_id` (stable for the whole session). Legacy +/// fallback: conversation uuid + message id on iOS, conversation uuid on +/// Android. abstract final class VideoCallKitId { static final RegExp _uuidPrefix = RegExp( r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$', ); + static String resolve({ + String? callId, + required String conversationUuid, + int? messageId, + required bool isIos, + }) { + final serverCallId = callId?.trim(); + if (serverCallId != null && serverCallId.isNotEmpty) { + return serverCallId; + } + if (isIos) { + return iosUuid( + conversationUuid: conversationUuid, + messageId: messageId, + ) ?? + conversationUuid; + } + return conversationUuid; + } + static String? iosUuid({ required String conversationUuid, required int? messageId, diff --git a/lib/features/video_calls/services/video_call_peer_departure.dart b/lib/features/video_calls/services/video_call_peer_departure.dart index 7db7ba16..86d6f697 100644 --- a/lib/features/video_calls/services/video_call_peer_departure.dart +++ b/lib/features/video_calls/services/video_call_peer_departure.dart @@ -18,15 +18,14 @@ bool shouldForceEndCallAfterPeerLeft({ return !videoCallRoomStillHasRemoteUser(remoteIdentities); } -/// The participant who taps hang up must not report the end while the other -/// person is still in the room. That report and the remaining client's report -/// both see an empty room and each one rings the other user again. +/// Hang up reports the end the same way the web client does after it leaves +/// the room. The server then sends the ended chat message and push when no +/// other person is left in the room, which drops the call on the other devices. bool shouldNotifyCallEnded({ required bool alreadyNotified, required bool hasConversation, - required bool peerStillInRoom, }) { - if (!hasConversation || alreadyNotified || peerStillInRoom) return false; + if (!hasConversation || alreadyNotified) return false; return true; } @@ -91,3 +90,44 @@ bool shouldForceEndCallAfterRoomDisconnect({ return reason == lk.DisconnectReason.participantRemoved || reason == lk.DisconnectReason.roomDeleted; } + +/// Starting an incoming call must not run [endCall]. That dismisses the +/// CallKit call which just activated the microphone, and an empty +/// conversation id dismisses every active CallKit call. +bool shouldPreserveCallKitWhenStartingCall({ + required String previousConversationUuid, + required String nextConversationUuid, +}) { + final previous = previousConversationUuid.trim(); + final next = nextConversationUuid.trim(); + if (previous.isEmpty) return true; + return previous == next; +} + +/// `POST /decline` and `POST /ended` treat `call_id` as the session uuid. +/// The conversation uuid is a different id; sending it makes the request 404 +/// and the other devices never hear about the hang-up. +String? backendVideoCallId({ + required String? callId, + required String conversationUuid, +}) { + final id = callId?.trim() ?? ''; + if (id.isEmpty) return null; + if (id == conversationUuid.trim()) return null; + return id; +} + +/// [startCall] emits an empty idle snapshot while it replaces the room. +/// A real failure (lost link or a connect error) still has to reach the +/// visible call screen, otherwise the loading screen never leaves. +bool isTransientIdleSnapshotWhileCallScreenVisible({ + required bool callScreenVisible, + required String conversationUuid, + required bool needsManualReconnect, + required String? connectError, +}) { + if (!callScreenVisible) return false; + if (needsManualReconnect) return false; + if (connectError != null && connectError.trim().isNotEmpty) return false; + return conversationUuid.trim().isEmpty; +} diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index 4a4c2418..68dc7546 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -6,6 +6,8 @@ class VideoCallPushPayload { this.localParticipantName, this.counterpartIsConsultant, this.messageId, + this.callId, + this.expiresAt, }); final String conversationUuid; @@ -14,6 +16,8 @@ class VideoCallPushPayload { final String? localParticipantName; final bool? counterpartIsConsultant; final int? messageId; + final String? callId; + final DateTime? expiresAt; static VideoCallPushPayload? tryParse(Map data) { if (!isIncomingVideoCall(data)) return null; @@ -52,6 +56,8 @@ class VideoCallPushPayload { 'isConsultant', ]), messageId: _messageId(data), + callId: _string(data, const ['call_id', 'callId']), + expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']), ); } @@ -119,6 +125,9 @@ class VideoCallPushPayload { static String? conversationUuidFromData(Map data) => _conversationUuid(data); + static String? callIdFromData(Map data) => + _string(data, const ['call_id', 'callId']); + static String? _conversationUuid(Map data) { final fromData = _string(data, const [ 'conversationUuid', @@ -181,6 +190,17 @@ class VideoCallPushPayload { return null; } + static DateTime? _dateTime(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is DateTime) return value; + if (value is String && value.trim().isNotEmpty) { + return DateTime.tryParse(value.trim()); + } + } + return null; + } + static bool _isTruthy(Object? value) { if (value == null) return false; if (value is bool) return value; diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart index eff4e99b..b87fc03a 100644 --- a/lib/features/video_calls/services/video_call_service.dart +++ b/lib/features/video_calls/services/video_call_service.dart @@ -11,6 +11,7 @@ abstract class VideoCallService { @GET('/api/v2/chat/{conversation}/video-call/token') Future getParticipantToken({ @Path('conversation') required String conversationUuid, + @Query('call_id') String? callId, }); @GET('/api/v2/chat/{conversation}/video-call/admin-token') @@ -29,4 +30,10 @@ abstract class VideoCallService { @Path('conversation') required String conversationUuid, @Body() required Map body, }); + + @POST('/api/v2/chat/{conversation}/video-call/decline') + Future notifyCallDeclined({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); } diff --git a/lib/features/video_calls/services/video_call_socket_signal.dart b/lib/features/video_calls/services/video_call_socket_signal.dart new file mode 100644 index 00000000..0ad5a8ad --- /dev/null +++ b/lib/features/video_calls/services/video_call_socket_signal.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; + +/// `video-call-declined` / `video-call-ended` on `private-new-chat-messages`. +/// +/// The web client closes every other device from these events. A decline does +/// not create a chat message, so the open app has to read the socket itself. +class VideoCallSocketSignal { + const VideoCallSocketSignal({ + required this.endsActiveCall, + this.conversationUuid, + this.callId, + this.reason, + this.acceptedDeviceId, + this.acceptedClient, + }); + + final bool endsActiveCall; + final String? conversationUuid; + final String? callId; + final VideoCallEndReason? reason; + final String? acceptedDeviceId; + final String? acceptedClient; + + static VideoCallSocketSignal? tryParseDeclined(Object? raw) { + return _tryParse(raw, declined: true); + } + + static VideoCallSocketSignal? tryParseEnded(Object? raw) { + return _tryParse(raw, declined: false); + } + + static VideoCallSocketSignal? _tryParse( + Object? raw, { + required bool declined, + }) { + final data = _asMap(raw); + if (data == null) return null; + + final conversationUuid = _string(data, const [ + 'conversation_uuid', + 'conversationUuid', + ]); + final callId = _string(data, const ['call_id', 'callId']); + if ((conversationUuid == null || conversationUuid.isEmpty) && + (callId == null || callId.isEmpty)) { + return null; + } + + final reason = declined + ? VideoCallEndReason.declined + : VideoCallEndReason.tryParse(data['reason'] ?? data['status']); + final endsActiveCall = declined || reason != VideoCallEndReason.accepted; + + return VideoCallSocketSignal( + endsActiveCall: endsActiveCall, + conversationUuid: conversationUuid, + callId: callId, + reason: reason, + acceptedDeviceId: _string(data, const [ + 'accepted_device_id', + 'acceptedDeviceId', + ]), + acceptedClient: _string(data, const [ + 'accepted_client', + 'acceptedClient', + ]), + ); + } + + static Map? _asMap(Object? raw) { + if (raw is Map) { + return raw.map((key, value) => MapEntry(key.toString(), value)); + } + if (raw is! String || raw.trim().isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) {} + return null; + } + + static String? _string(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + } + return null; + } +} diff --git a/lib/features/video_calls/services/video_call_tombstone_registry.dart b/lib/features/video_calls/services/video_call_tombstone_registry.dart new file mode 100644 index 00000000..af5ee27d --- /dev/null +++ b/lib/features/video_calls/services/video_call_tombstone_registry.dart @@ -0,0 +1,37 @@ +/// Remembers cancelled / declined call ids so a late `video_call_started` +/// push does not show an already-finished ring. +abstract final class VideoCallTombstoneRegistry { + static const Duration defaultTtl = Duration(minutes: 2); + + static final Map _expiresAtByCallId = {}; + + static void mark( + String? callId, { + DateTime? now, + Duration ttl = defaultTtl, + }) { + final id = _normalize(callId); + if (id == null) return; + final at = now ?? DateTime.now(); + _expiresAtByCallId[id] = at.add(ttl); + } + + static bool contains(String? callId, {DateTime? now}) { + final id = _normalize(callId); + if (id == null) return false; + _prune(now ?? DateTime.now()); + return _expiresAtByCallId.containsKey(id); + } + + static void clear() => _expiresAtByCallId.clear(); + + static void _prune(DateTime now) { + _expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now)); + } + + static String? _normalize(String? callId) { + final id = callId?.trim().toLowerCase(); + if (id == null || id.isEmpty) return null; + return id; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 95c01f2f..70b070f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.59+148 -publish_to: "none" +version: 1.3.67+156 +publish_to: 'none' environment: sdk: ^3.10.4 diff --git a/test/core/app_config_test.dart b/test/core/app_config_test.dart index 9f72563d..4aa8dc37 100644 --- a/test/core/app_config_test.dart +++ b/test/core/app_config_test.dart @@ -111,6 +111,7 @@ void main() { prefs.getString(PrefsKeys.customBaseUrl), 'https://custom.example', ); + expect(prefs.getString(PrefsKeys.apiBaseUrl), 'https://custom.example'); }); test('setBaseUrl removes cached override when url is blank', () async { @@ -145,9 +146,14 @@ void main() { ); applyCachedBaseUrl(); + await Future.delayed(Duration.zero); expect(sl().baseUrl, 'https://cached.example'); expect(sl().options.baseUrl, 'https://cached.example'); + expect( + sl().getString(PrefsKeys.apiBaseUrl), + 'https://cached.example', + ); }, ); @@ -162,9 +168,11 @@ void main() { ); applyCachedBaseUrl(); + await Future.delayed(Duration.zero); expect(sl().baseUrl, EnvConfig.baseUrl); expect(sl().options.baseUrl, EnvConfig.baseUrl); + expect(sl().getString(PrefsKeys.apiBaseUrl), EnvConfig.baseUrl); }); test('setBaseUrl can be called multiple times', () { diff --git a/test/core/device/device_hw_id_provider_test.dart b/test/core/device/device_hw_id_provider_test.dart new file mode 100644 index 00000000..c08aece7 --- /dev/null +++ b/test/core/device/device_hw_id_provider_test.dart @@ -0,0 +1,23 @@ +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('android install id stays the same for this install', () async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + + final first = await DeviceHwIdProvider.androidInstallId( + preferences: preferences, + ); + final second = await DeviceHwIdProvider.androidInstallId( + preferences: preferences, + ); + + expect(first.length, greaterThanOrEqualTo(8)); + expect(second, first); + expect(first.contains('-'), isTrue); + }); +} diff --git a/test/core/enums/prefs_keys_test.dart b/test/core/enums/prefs_keys_test.dart index fe2f15cc..bb77e7cb 100644 --- a/test/core/enums/prefs_keys_test.dart +++ b/test/core/enums/prefs_keys_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('has all expected values', () { - expect(PrefsKeys.values.length, 6); + expect(PrefsKeys.values.length, 7); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); @@ -14,6 +14,7 @@ void main() { ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); + expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl)); }); test('can be compared', () { @@ -44,6 +45,7 @@ void main() { 'isEmailVerificationSkipped', ); expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); + expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl'); }); }); } diff --git a/test/core/prefs_keys_test.dart b/test/core/prefs_keys_test.dart index 98785895..34578946 100644 --- a/test/core/prefs_keys_test.dart +++ b/test/core/prefs_keys_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('все значения enum определены', () { - expect(PrefsKeys.values.length, 6); + expect(PrefsKeys.values.length, 7); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); @@ -14,6 +14,7 @@ void main() { ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); + expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl)); }); test('имена ключей корректны', () { @@ -32,6 +33,7 @@ void main() { 'isEmailVerificationSkipped', ); expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); + expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl'); }); test('can be compared', () { diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 755164eb..5ad5c65a 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -89,6 +89,7 @@ void main() { required int authorId, required String roomId, String data = 'socket message', + String? callId, }) { return _FakeChannelReadEvent( jsonEncode({ @@ -100,6 +101,7 @@ void main() { 'read_by_to_user': false, 'is_author_message_auth_user': false, 'room_id': roomId, + if (callId != null) 'call_id': callId, }), ); } @@ -418,8 +420,10 @@ void main() { () async { const chatId = 'chat-id'; final presented = []; + String? presentedCallId; chatsCubit.presentIncomingVideoCall = (payload) async { presented.add(payload.conversationUuid); + presentedCallId = payload.callId; }; chatsCubit.emit( ChatsState( @@ -436,11 +440,13 @@ void main() { authorId: 7, roomId: chatId, data: 'Анна начал(а) видеозвонок.', + callId: '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff', ), ); await Future.delayed(const Duration(milliseconds: 100)); expect(presented, [chatId]); + expect(presentedCallId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff'); await controller.close(); }, @@ -1027,6 +1033,120 @@ void main() { await reconnectController.close(); }, ); + + test('video-call-declined closes the call on this device', () async { + final declined = StreamController.broadcast(); + when(mockWsManager.newMessagesStream).thenReturn(null); + when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty()); + when( + mockWsManager.videoCallDeclinedStream, + ).thenAnswer((_) => declined.stream); + when(mockWsManager.videoCallEndedStream).thenReturn(null); + + final dismissed = []; + final ended = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async { + ended.add(conversationUuid); + expect(callId, 'call-1'); + }; + + await chatsCubit.connectToWebSocket(); + declined.add( + _FakeChannelReadEvent( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'declined', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, ['conv-1']); + expect(ended, ['conv-1']); + await declined.close(); + }); + + test('video-call-accepted closes ringing on the other device', () async { + final accepted = StreamController.broadcast(); + when(mockWsManager.newMessagesStream).thenReturn(null); + when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty()); + when(mockWsManager.videoCallDeclinedStream).thenReturn(null); + when(mockWsManager.videoCallEndedStream).thenReturn(null); + when( + mockWsManager.videoCallAcceptedStream, + ).thenAnswer((_) => accepted.stream); + chatsCubit.currentDeviceHwId = () async => 'device-iphone'; + + final dismissed = []; + final ended = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async { + ended.add(conversationUuid); + }; + + await chatsCubit.connectToWebSocket(); + accepted.add( + _FakeChannelReadEvent( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'accepted', + 'accepted_device_id': 'device-android', + 'accepted_client': 'android', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, ['conv-1']); + expect(ended, isEmpty); + await accepted.close(); + }); + + test( + 'video-call-accepted keeps the call on the device that answered', + () async { + final accepted = StreamController.broadcast(); + when(mockWsManager.newMessagesStream).thenReturn(null); + when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty()); + when(mockWsManager.videoCallDeclinedStream).thenReturn(null); + when(mockWsManager.videoCallEndedStream).thenReturn(null); + when( + mockWsManager.videoCallAcceptedStream, + ).thenAnswer((_) => accepted.stream); + chatsCubit.currentDeviceHwId = () async => 'Device-iPhone'; + + final dismissed = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async { + fail('accepted call must stay on the answering device'); + }; + + await chatsCubit.connectToWebSocket(); + accepted.add( + _FakeChannelReadEvent( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'accepted', + 'accepted_device_id': 'device-iphone', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, isEmpty); + await accepted.close(); + }, + ); }); } diff --git a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart index f7a271d6..64648b8f 100644 --- a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart +++ b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart @@ -1,5 +1,4 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; @@ -52,6 +51,7 @@ void main() { videoCallRepository: mockRepository, foregroundCoordinator: foreground, lifecycleBinding: lifecycleBinding, + resolveHwId: () async => 'test-hw-id-android-01', ); mockRoom = MockRoom(); when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); @@ -94,6 +94,13 @@ void main() { await manager.declineIncomingCall('test-uuid'); verifyNever(mockRepository.notifyCallEnded(any)); + verifyNever( + mockRepository.notifyCallDeclined( + conversationUuid: anyNamed('conversationUuid'), + hwId: anyNamed('hwId'), + callId: anyNamed('callId'), + ), + ); }, ); @@ -112,15 +119,18 @@ void main() { ); test( - 'decline of a call that was never joined notifies the backend', + 'decline of a call that was never joined does not POST /decline from Dart', () async { - when( - mockRepository.notifyCallEnded(any), - ).thenAnswer((_) async => const VideoCallEndedResponse(sent: true)); - await manager.declineIncomingCall('other-uuid'); - verify(mockRepository.notifyCallEnded('other-uuid')).called(1); + verifyNever( + mockRepository.notifyCallDeclined( + conversationUuid: anyNamed('conversationUuid'), + hwId: anyNamed('hwId'), + callId: anyNamed('callId'), + ), + ); + verifyNever(mockRepository.notifyCallEnded(any)); }, ); diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index a69cf9cd..bd3a1e39 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -257,7 +257,7 @@ void main() { conversationUuid: 'uuid', counterpartName: anyNamed('counterpartName'), localParticipantName: anyNamed('localParticipantName'), - micEnabled: false, + micEnabled: true, videoEnabled: false, ), ).called(1); @@ -357,7 +357,7 @@ void main() { conversationUuid: 'uuid', counterpartName: anyNamed('counterpartName'), localParticipantName: anyNamed('localParticipantName'), - micEnabled: false, + micEnabled: true, videoEnabled: false, ), ).called(1); diff --git a/test/features/video_calls/repositories/video_call_repository_test.dart b/test/features/video_calls/repositories/video_call_repository_test.dart index 368a839e..60b5bb83 100644 --- a/test/features/video_calls/repositories/video_call_repository_test.dart +++ b/test/features/video_calls/repositories/video_call_repository_test.dart @@ -24,8 +24,10 @@ void main() { final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); final expected = VideoCallTokenResponse.fromJson(json); - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenAnswer((_) async => expected); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenAnswer((_) async => expected); final result = await repository.getParticipantToken(conversationUuid); @@ -33,20 +35,28 @@ void main() { expect(result.token, expected.token); expect(result.wsUrl, expected.wsUrl); expect(result.roomName, expected.roomName); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getParticipantToken - пробрасывает ошибку сервиса', () async { const conversationUuid = 'conv-uuid-broken'; - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenThrow(Exception('No token')); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenThrow(Exception('No token')); expect( () => repository.getParticipantToken(conversationUuid), throwsA(isA()), ); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async { @@ -91,6 +101,60 @@ void main() { expect(captured.single as Map, isEmpty); }); + test('notifyCallEnded - передаёт call_id когда он известен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + await repository.notifyCallEnded(conversationUuid, callId: 'call-1'); + + final captured = verify( + mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured.single as Map; + expect(captured, {'call_id': 'call-1'}); + }); + + test('notifyCallDeclined - передаёт hw_id и call_id', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap( + 'video_calls/video_call_decline_response.json', + ); + final expected = VideoCallDeclineResponse.fromJson(json); + + when(mockService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + final result = await repository.notifyCallDeclined( + conversationUuid: conversationUuid, + hwId: 'device-android-001', + callId: 'call-1', + ); + + expect(result.sent, isTrue); + expect(result.devicesNotified, 1); + + final captured = verify( + mockService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured.single as Map; + expect(captured, { + 'hw_id': 'device-android-001', + 'call_id': 'call-1', + }); + }); + test('notifyCallEnded - пробрасывает ошибку сервиса', () async { const conversationUuid = 'conv-uuid-broken'; diff --git a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart index 71b266f6..6cf009ae 100644 --- a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart +++ b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -28,6 +29,59 @@ void main() { expect(calls, ['activate', 'activate', 'restart', 'deactivate']); }); + test('startCallKitCapture starts the AudioEngine ADM without stopping', () async { + final adm = []; + final coordinator = VideoCallAudioSessionCoordinator( + isIos: true, + iosCallAudioSession: (_) async {}, + stopLocalRecording: () async => adm.add('stop'), + startLocalRecording: () async => adm.add('start'), + ); + + await coordinator.startCallKitCapture(); + + expect(coordinator.isActive, isTrue); + expect(adm, ['start']); + }); + + test('stop stops the AudioEngine ADM', () async { + final adm = []; + final coordinator = VideoCallAudioSessionCoordinator( + isIos: true, + iosCallAudioSession: (_) async {}, + stopLocalRecording: () async => adm.add('stop'), + startLocalRecording: () async => adm.add('start'), + ); + + await coordinator.startCallKitCapture(); + await coordinator.stop(); + + expect(coordinator.isActive, isFalse); + expect(adm, ['start', 'stop']); + }); + + test('forwards a later CallKit audio activation', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final coordinator = VideoCallAudioSessionCoordinator(isIos: true); + var activations = 0; + coordinator.onIosAudioSessionActivated = () async { + activations++; + }; + + const codec = StandardMethodCodec(); + final data = codec.encodeMethodCall( + const MethodCall('audioSessionActivated'), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + 'com.eatvioapp/call_audio_session', + data, + (_) {}, + ); + + expect(activations, 1); + }); + test('stop clears the active session when deactivate fails', () async { final coordinator = VideoCallAudioSessionCoordinator( isIos: true, diff --git a/test/features/video_calls/services/video_call_chat_signal_test.dart b/test/features/video_calls/services/video_call_chat_signal_test.dart index a34d6529..7eb553f1 100644 --- a/test/features/video_calls/services/video_call_chat_signal_test.dart +++ b/test/features/video_calls/services/video_call_chat_signal_test.dart @@ -33,6 +33,15 @@ void main() { expect(payload.messageId, 1); }); + test('keeps the server call id on the incoming payload', () { + final payload = VideoCallChatSignal.incomingPayload( + message: message(data: 'Анна начал(а) видеозвонок.'), + callId: ' 6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff ', + ); + + expect(payload?.callId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff'); + }); + test('recognizes an english realtime video call start', () { expect( VideoCallChatSignal.kindOf(message(data: 'Anna started a video call.')), diff --git a/test/features/video_calls/services/video_call_ended_push_handler_test.dart b/test/features/video_calls/services/video_call_ended_push_handler_test.dart index 90df2f1a..3f5e0d2c 100644 --- a/test/features/video_calls/services/video_call_ended_push_handler_test.dart +++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart @@ -47,6 +47,53 @@ void main() { }); }); + group('VideoCallEndedPushHandler.callMatches', () { + test('matches by call_id and ignores conversation fallback', () { + const call = CallKitParams( + id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + handle: conversation, + extra: { + 'conversationUuid': conversation, + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + }, + ); + + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ), + isTrue, + ); + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: 'stale-call-id', + ), + isFalse, + ); + }); + + test('a legacy ring without call_id still matches the conversation', () { + const call = CallKitParams( + id: conversation, + handle: conversation, + extra: {'conversationUuid': conversation}, + ); + + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ), + isTrue, + ); + }); + }); + group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () { final calls = []; late List> activeCalls; @@ -93,6 +140,40 @@ void main() { expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse); }); + test('ends only the matching call_id', () async { + activeCalls = >[ + { + 'id': 'call-a', + 'handle': conversation, + 'extra': { + 'conversationUuid': conversation, + 'call_id': 'call-a', + }, + }, + { + 'id': 'call-b', + 'handle': conversation, + 'extra': { + 'conversationUuid': conversation, + 'call_id': 'call-b', + }, + }, + ]; + + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: conversation, + callId: 'call-a', + ); + + final ended = calls + .where((call) => call.method == 'endCall') + .map((call) => (call.arguments as Map)['id']) + .toList(); + expect(ended, ['call-a']); + expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue); + expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse); + }); + test( 'without a conversation ends every call and marks all of them', () async { @@ -103,5 +184,18 @@ void main() { expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue); }, ); + + test('dismissCallKitOnly ends the matching CallKit call', () async { + await VideoCallEndedPushHandler.dismissCallKitOnly( + conversationUuid: conversation, + ); + + final ended = calls + .where((call) => call.method == 'endCall') + .map((call) => (call.arguments as Map)['id']) + .toList(); + expect(ended, ['ringing-id']); + expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue); + }); }); } diff --git a/test/features/video_calls/services/video_call_peer_departure_test.dart b/test/features/video_calls/services/video_call_peer_departure_test.dart index 30a9c38b..216e330b 100644 --- a/test/features/video_calls/services/video_call_peer_departure_test.dart +++ b/test/features/video_calls/services/video_call_peer_departure_test.dart @@ -66,23 +66,11 @@ void main() { ); }); - test('does not report the end while the other user is still in the room', () { + test('reports the end so the other devices drop the call', () { expect( shouldNotifyCallEnded( alreadyNotified: false, hasConversation: true, - peerStillInRoom: true, - ), - isFalse, - ); - }); - - test('reports the end when this client is the last participant', () { - expect( - shouldNotifyCallEnded( - alreadyNotified: false, - hasConversation: true, - peerStillInRoom: false, ), isTrue, ); @@ -90,7 +78,13 @@ void main() { shouldNotifyCallEnded( alreadyNotified: true, hasConversation: true, - peerStillInRoom: false, + ), + isFalse, + ); + expect( + shouldNotifyCallEnded( + alreadyNotified: false, + hasConversation: false, ), isFalse, ); @@ -118,6 +112,78 @@ void main() { ); }); + test('an incoming start does not tear down the accepted CallKit call', () { + expect( + shouldPreserveCallKitWhenStartingCall( + previousConversationUuid: '', + nextConversationUuid: 'conv-1', + ), + isTrue, + ); + expect( + shouldPreserveCallKitWhenStartingCall( + previousConversationUuid: 'conv-1', + nextConversationUuid: 'conv-1', + ), + isTrue, + ); + expect( + shouldPreserveCallKitWhenStartingCall( + previousConversationUuid: 'conv-old', + nextConversationUuid: 'conv-1', + ), + isFalse, + ); + }); + + test('the conversation uuid is not sent as the session call id', () { + expect( + backendVideoCallId(callId: 'conv-1', conversationUuid: 'conv-1'), + isNull, + ); + expect( + backendVideoCallId( + callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + conversationUuid: 'conv-1', + ), + '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ); + expect( + backendVideoCallId(callId: ' ', conversationUuid: 'conv-1'), + isNull, + ); + }); + + test('a lost connection on the visible call screen is not ignored', () { + expect( + isTransientIdleSnapshotWhileCallScreenVisible( + callScreenVisible: true, + conversationUuid: '', + needsManualReconnect: false, + connectError: null, + ), + isTrue, + ); + expect( + isTransientIdleSnapshotWhileCallScreenVisible( + callScreenVisible: true, + conversationUuid: 'conv-1', + needsManualReconnect: true, + connectError: 'timeout', + ), + isFalse, + ); + expect( + isTransientIdleSnapshotWhileCallScreenVisible( + callScreenVisible: false, + conversationUuid: '', + needsManualReconnect: false, + connectError: null, + ), + isFalse, + ); + }); + test('a token is not requested again right after leaving', () { final leftAt = DateTime(2026, 10, 16, 12); expect( diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 07ad8574..a0dd6a1e 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -60,6 +60,24 @@ void main() { expect(payload!.conversationUuid, 'abc-123'); }); + test('parses ended video call push with call_id and reason', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + 'reason': 'declined', + 'accepted_device_id': 'device-a', + 'accepted_client': 'android', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); + expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'); + expect(payload.reason?.name, 'declined'); + expect(payload.acceptedDeviceId, 'device-a'); + expect(payload.acceptedClient, 'android'); + }); + test('parses message id for a stable CallKit uuid', () { final payload = VideoCallPushPayload.tryParse({ 'type': 'video_call_started', @@ -77,6 +95,28 @@ void main() { ); }); + test('prefers call_id as CallKit id when present', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_started', + 'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000', + 'message_id': '42', + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + 'expires_at': '2026-04-27T14:31:00+00:00', + }); + + expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'); + expect(payload?.expiresAt, isNotNull); + expect( + VideoCallKitId.resolve( + callId: payload!.callId, + conversationUuid: payload.conversationUuid, + messageId: payload.messageId, + isIos: true, + ), + '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ); + }); + test('ignores regular chat push', () { expect( VideoCallPushPayload.tryParse({ diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart index 4d2aa887..93daec33 100644 --- a/test/features/video_calls/services/video_call_service_test.dart +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -21,23 +21,32 @@ void main() { final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); final expected = VideoCallTokenResponse.fromJson(json); - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenAnswer((_) async => expected); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenAnswer((_) async => expected); - final result = await mockService.getParticipantToken(conversationUuid: conversationUuid); + final result = await mockService.getParticipantToken( + conversationUuid: conversationUuid, + ); expect(result, isA()); expect(result.token, expected.token); expect(result.wsUrl, expected.wsUrl); expect(result.roomName, expected.roomName); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getParticipantToken - пробрасывает ошибку', () async { const conversationUuid = 'conv-uuid-error'; - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenThrow(Exception('Network error')); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenThrow(Exception('Network error')); expect( () => mockService.getParticipantToken(conversationUuid: conversationUuid), diff --git a/test/features/video_calls/services/video_call_socket_signal_test.dart b/test/features/video_calls/services/video_call_socket_signal_test.dart new file mode 100644 index 00000000..f91b6c4b --- /dev/null +++ b/test/features/video_calls/services/video_call_socket_signal_test.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('decline closes the caller call and the other ringing devices', () { + final signal = VideoCallSocketSignal.tryParseDeclined( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'declined', + }), + ); + + expect(signal, isNotNull); + expect(signal!.endsActiveCall, isTrue); + expect(signal.conversationUuid, 'conv-1'); + expect(signal.callId, 'call-1'); + expect(signal.reason, VideoCallEndReason.declined); + }); + + test('ended closes an active call', () { + final signal = VideoCallSocketSignal.tryParseEnded({ + 'conversation_uuid': 'conv-1', + 'reason': 'cancelled', + }); + + expect(signal?.endsActiveCall, isTrue); + expect(signal?.reason, VideoCallEndReason.cancelled); + }); + + test('an accept marker does not tear down the call this device joined', () { + final signal = VideoCallSocketSignal.tryParseEnded({ + 'call_id': 'call-1', + 'status': 'accepted', + 'accepted_device_id': 'device-iphone', + 'accepted_client': 'ios', + }); + + expect(signal?.endsActiveCall, isFalse); + expect(signal?.reason, VideoCallEndReason.accepted); + expect(signal?.acceptedDeviceId, 'device-iphone'); + expect(signal?.acceptedClient, 'ios'); + }); + + test('ignores a payload without a call or a conversation', () { + expect( + VideoCallSocketSignal.tryParseDeclined({ + 'status': 'declined', + }), + isNull, + ); + }); +} diff --git a/test/features/video_calls/services/video_call_tombstone_registry_test.dart b/test/features/video_calls/services/video_call_tombstone_registry_test.dart new file mode 100644 index 00000000..f66ac2e5 --- /dev/null +++ b/test/features/video_calls/services/video_call_tombstone_registry_test.dart @@ -0,0 +1,22 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(VideoCallTombstoneRegistry.clear); + + test('marks a call id so a late incoming can be suppressed', () { + const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'; + final now = DateTime.utc(2026, 10, 21, 10); + + VideoCallTombstoneRegistry.mark(callId, now: now); + + expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue); + expect( + VideoCallTombstoneRegistry.contains( + callId, + now: now.add(const Duration(minutes: 3)), + ), + isFalse, + ); + }); +} diff --git a/test/fixtures/video_calls/video_call_decline_response.json b/test/fixtures/video_calls/video_call_decline_response.json new file mode 100644 index 00000000..783f7127 --- /dev/null +++ b/test/fixtures/video_calls/video_call_decline_response.json @@ -0,0 +1,6 @@ +{ + "sent": true, + "devices_notified": 1, + "call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b", + "status": "declined" +}