merge upstream

This commit is contained in:
2026-10-24 06:49:09 +00:00
62 changed files with 3196 additions and 365 deletions

View File

@@ -39,7 +39,7 @@ android {
versionName = flutter.versionName
manifestPlaceholders = [
YANDEX_CLIENT_ID: yandexClientId,
applicationName: "io.flutter.app.FlutterApplication"
applicationName: "com.eatvioapp.app.EatvioApplication"
]
}

View File

@@ -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<String, Any?>? {
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
return payloadMap(data, action)
}
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
@@ -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<String, Any?> {
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
}
}

View File

@@ -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)

View File

@@ -0,0 +1,10 @@
package com.eatvioapp.app
import android.app.Application
class EatvioApplication : Application() {
override fun onCreate() {
super.onCreate()
VideoCallDeclineReporter.install(this)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending decline id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<String, Any?> {
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)
}
}
}
}

View File

@@ -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>): 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\""
}
}

View File

@@ -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<String, Long>()
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<String, String>) {
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<String, String>): 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<String, Any?>()
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, String>): 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<String, Any?>
?: 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, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]

View File

@@ -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<String, Any?>())
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)
}
}

View File

@@ -650,7 +650,7 @@ code + .copy-button {
<script type="text/javascript">
function configurationCacheProblems() { return (
// begin-report-data
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
// end-report-data
);}
</script>

View File

@@ -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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -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 */,
);

View File

@@ -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<String>()
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 {

View File

@@ -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)
}
}

View File

@@ -48,6 +48,7 @@ class AppConfig {
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}
/// Список глобальных BlocProvider'ов

View File

@@ -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<Prefs>() || !sl.isRegistered<AppConfig>()) {
return;
}
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
if (cached == null || cached.isEmpty) {
if (cached != null && cached.isNotEmpty) {
sl<AppConfig>().setBaseUrl(cached);
return;
}
sl<AppConfig>().setBaseUrl(cached);
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
}
void _persistApiBaseUrl(String baseUrl) {
final normalized = baseUrl.trim();
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}

View File

@@ -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<String?> current() async {
final deviceInfo = DeviceInfoPlugin();
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
static Future<String?> 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<String> 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<bool> androidTokenNeedsReregister({
SharedPreferences? preferences,
}) async {
if (!Platform.isAndroid) return false;
final prefs = preferences ?? await SharedPreferences.getInstance();
return prefs.getBool(androidTokenMigratedKey) != true;
}
static Future<void> 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<int>.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)}';
}
}

View File

@@ -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<void> 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<Dio>().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<Dio>().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<Dio>()
.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<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'hw_id': hwId,
'token': token,
},
).timeout(

View File

@@ -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;

View File

@@ -5,4 +5,5 @@ enum PrefsKeys {
marketingBottomSheetHasBeenShown,
isEmailVerificationSkipped,
customBaseUrl,
apiBaseUrl,
}

View File

@@ -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<void> _reconnectController = StreamController<void>.broadcast();
StreamController<void> _reconnectController =
StreamController<void>.broadcast();
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? 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<ChannelReadEvent>? get videoCallAcceptedStream =>
_privateMessagesChannel?.bind('video-call-accepted');
Stream<ChannelReadEvent>? get newNotificationsStream =>
_privateNotificationChannel?.bind(
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
);
Stream<ChannelReadEvent>? 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<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().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<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().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<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().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<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().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);

View File

@@ -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<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
StreamSubscription? videoCallDeclinedSubscription;
StreamSubscription? videoCallEndedSubscription;
StreamSubscription? videoCallAcceptedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
/// Test seam. Production reads [DeviceHwIdProvider.current].
Future<String?> Function()? currentDeviceHwId;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
Future<void> Function({String? conversationUuid, String? callId})?
endActiveVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -55,6 +66,37 @@ class ChatsCubit extends Cubit<ChatsState> {
_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<ChatsState> {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
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<ChatsState> {
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
callId: callId,
);
} catch (e, st) {
await reportFrom(e, st, this);
@@ -227,12 +271,15 @@ class ChatsCubit extends Cubit<ChatsState> {
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> 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<ChatsState> {
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<ChatsState> {
}
}
Future<void> _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<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
final dismiss = dismissIncomingVideoCall;
@@ -278,6 +355,34 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
/// `video-call-accepted` also reaches the device that just answered.
/// Closing CallKit there hangs up the call that is starting.
Future<bool> _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<String, dynamic> json, List<String> 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<ChatRoom> chatRooms,
@@ -306,6 +411,9 @@ class ChatsCubit extends Cubit<ChatsState> {
@override
Future<void> close() {
newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
videoCallAcceptedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}

View File

@@ -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<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();

View File

@@ -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<String?> 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<String?> 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<String, DateTime> _leftConversationAt = {};
final Set<String> _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<void> notifyIncomingCallAccepted(String conversationUuid) async {
Future<void> 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<void> 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<void> _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<lk.ParticipantDisconnectedEvent>((_) {
..on<lk.ParticipantDisconnectedEvent>((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<void> _disconnectLiveKitWithTimeout() {
return _disconnectLiveKitOnly().timeout(
const Duration(seconds: 3),
onTimeout: () {},
);
}
Future<void> _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<void> _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<void> _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<void>.delayed(const Duration(milliseconds: 300));
if (!_iosMicCaptureReady) return;
await _audioSession.startCallKitCapture();
}
bool get _iosMicCaptureReady =>
Platform.isIOS && _snapshot.micEnabled && _callStageEntered;
Future<void> _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<void> _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 (_) {}
}
}

View File

@@ -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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> json) =>
_$VideoCallDeclineResponseFromJson(json);
}

View File

@@ -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<VideoCallSessionState> {
}
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<VideoCallSessionState> {
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<VideoCallSessionState> {
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
);
if (!started) {
@@ -361,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
_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<void>.delayed(Duration(milliseconds: 350 * (attempt + 1)));
}
} finally {
_autoEnterInFlight = false;
}
@@ -371,10 +388,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _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<VideoCallSessionState> {
Future<bool> _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<VideoCallSessionState> {
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;

View File

@@ -8,9 +8,13 @@ class VideoCallRepository {
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
Future<VideoCallTokenResponse> getParticipantToken(
String conversationUuid, {
String? callId,
}) {
return videoCallService.getParticipantToken(
conversationUuid: conversationUuid,
callId: callId,
);
}
@@ -18,20 +22,46 @@ class VideoCallRepository {
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
}
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
Future<VideoCallEndedResponse> notifyCallEnded(
String conversationUuid, {
String? callId,
}) {
final body = <String, dynamic>{
if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(),
};
return videoCallService.notifyCallEnded(
conversationUuid: conversationUuid,
body: _emptyJsonBody,
body: body.isEmpty ? _emptyJsonBody : body,
);
}
Future<VideoCallAcceptResponse> notifyCallAccepted({
required String conversationUuid,
required String hwId,
String? callId,
}) {
return videoCallService.notifyCallAccepted(
conversationUuid: conversationUuid,
body: <String, dynamic>{'hw_id': hwId},
body: <String, dynamic>{
'hw_id': hwId,
if (callId != null && callId.trim().isNotEmpty)
'call_id': callId.trim(),
},
);
}
Future<VideoCallDeclineResponse> notifyCallDeclined({
required String conversationUuid,
required String hwId,
String? callId,
}) {
return videoCallService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: <String, dynamic>{
'hw_id': hwId,
if (callId != null && callId.trim().isNotEmpty)
'call_id': callId.trim(),
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
typedef IosAdmHandler = Future<void> 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<void> Function()? onIosAudioSessionActivated;
static final AudioSessionConfiguration callConfiguration =
AudioSessionConfiguration(
@@ -54,6 +75,36 @@ class VideoCallAudioSessionCoordinator {
return _iosChannel.invokeMethod<void>(method);
}
static Future<void> _onIosAudioSessionCall(MethodCall call) async {
if (call.method != 'audioSessionActivated') return;
await _activationListener?.onIosAudioSessionActivated?.call();
}
static Future<void> _ensureWebRtcInitialized() async {
try {
await _webrtcChannel.invokeMethod<void>(
'initialize',
<String, dynamic>{'options': <String, dynamic>{}},
);
} catch (_) {}
}
static Future<void> _invokeStopLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'stopLocalRecording',
<String, dynamic>{},
);
}
static Future<void> _invokeStartLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'startLocalRecording',
<String, dynamic>{},
);
}
Future<void> 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<bool> 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<void> startCallKitCapture() async {
if (!_isIos) return;
try {
await _startLocalRecording();
_iosActive = true;
} catch (_) {}
}
Future<void> stop() async {
if (_isIos) {
_iosActive = false;
try {
await _stopLocalRecording();
} catch (_) {}
try {
await _iosCallAudioSession('deactivate');
} catch (_) {}

View File

@@ -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<String, CallKitParams> _callKitParamsById = {};
final Set<String> _acceptNavigationCompletedIds = {};
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
final Map<String, Future<void>> _acceptDeliveriesByConversation = {};
final Set<String> _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<void> _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<void> _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<Object?>('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<void> _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<void> _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<void> _handleNativeDeclinedVoipCall(Object? arguments) async {
if (!Platform.isIOS) return;
final data = _stringKeyedMap(arguments);
final extra = _stringKeyedMap(data['extra']);
if (callKitExtraMarksEndedPush(Map<String, dynamic>.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<void> _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 <String, Object?>{}) ??
(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<void>.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 = <String>{
callKitId,
conversationUuid,
?callId,
};
unawaited(
Future<void>.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<void> _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<void> _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<void>.delayed(const Duration(milliseconds: 100));
}
}
void _suppressCallEnd(String id) {
final trimmed = id.trim();
if (trimmed.isEmpty) return;
_suppressedEndCallKitIds.add(trimmed);
}
Future<void> _settleUiAfterResume() async {
@@ -859,6 +1015,7 @@ class VideoCallCallKitCoordinator {
Future<void> _handleDecline(
String callKitId, {
Map<String, dynamic>? 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<String, Object?> extra) {
return _parseString(extra, const ['call_id', 'callId']);
}
static String? _parseString(Map<String, Object?> map, List<String> keys) {
for (final key in keys) {
final value = map[key];

View File

@@ -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,
);
}

View File

@@ -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;
}

View File

@@ -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<void> dismissIncomingCallUi({String? conversationUuid}) async {
await endActiveCallIfMatches(conversationUuid);
static Future<void> 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<void>('dismissIncomingCalls');
await _androidPushChannel.invokeMethod<void>(
'dismissIncomingCalls',
<String, String?>{
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<void> 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<void> dismissCallKitOnly({
String? conversationUuid,
String? callId,
}) {
return _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: callId,
);
}
static Future<void> endActiveCallIfMatches({
String? conversationUuid,
String? callId,
}) async {
final uuid = conversationUuid?.trim();
if (uuid == null || uuid.isEmpty) return;
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
final cubit = sl<VideoCallSessionCubit>();
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<void> _dismissViaCallKit(String? conversationUuid) async {
static Future<void> _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);

View File

@@ -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<String, dynamic> 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<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
}

View File

@@ -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(

View File

@@ -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: <String, dynamic>{
'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<void>('showIncomingCall', <String, String>{
'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 (_) {}
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> data) =>
_conversationUuid(data);
static String? callIdFromData(Map<String, dynamic> data) =>
_string(data, const ['call_id', 'callId']);
static String? _conversationUuid(Map<String, dynamic> data) {
final fromData = _string(data, const [
'conversationUuid',
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
return null;
}
static DateTime? _dateTime(Map<String, dynamic> data, List<String> 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;

View File

@@ -11,6 +11,7 @@ abstract class VideoCallService {
@GET('/api/v2/chat/{conversation}/video-call/token')
Future<VideoCallTokenResponse> 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<String, dynamic> body,
});
@POST('/api/v2/chat/{conversation}/video-call/decline')
Future<VideoCallDeclineResponse> notifyCallDeclined({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -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<String, dynamic>? _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<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
}

View File

@@ -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<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
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;
}
}

View File

@@ -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

View File

@@ -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<void>.delayed(Duration.zero);
expect(sl<AppConfig>().baseUrl, 'https://cached.example');
expect(sl<Dio>().options.baseUrl, 'https://cached.example');
expect(
sl<Prefs>().getString(PrefsKeys.apiBaseUrl),
'https://cached.example',
);
},
);
@@ -162,9 +168,11 @@ void main() {
);
applyCachedBaseUrl();
await Future<void>.delayed(Duration.zero);
expect(sl<AppConfig>().baseUrl, EnvConfig.baseUrl);
expect(sl<Dio>().options.baseUrl, EnvConfig.baseUrl);
expect(sl<Prefs>().getString(PrefsKeys.apiBaseUrl), EnvConfig.baseUrl);
});
test('setBaseUrl can be called multiple times', () {

View File

@@ -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(<String, Object>{});
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);
});
}

View File

@@ -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');
});
});
}

View File

@@ -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', () {

View File

@@ -89,6 +89,7 @@ void main() {
required int authorId,
required String roomId,
String data = 'socket message',
String? callId,
}) {
return _FakeChannelReadEvent(
jsonEncode(<String, dynamic>{
@@ -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>[];
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<ChannelReadEvent>.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 = <String?>[];
final ended = <String?>[];
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(<String, dynamic>{
'call_id': 'call-1',
'conversation_uuid': 'conv-1',
'status': 'declined',
}),
),
);
await Future<void>.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<ChannelReadEvent>.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 = <String?>[];
final ended = <String?>[];
chatsCubit.dismissIncomingVideoCall = (uuid) async {
dismissed.add(uuid);
};
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
ended.add(conversationUuid);
};
await chatsCubit.connectToWebSocket();
accepted.add(
_FakeChannelReadEvent(
jsonEncode(<String, dynamic>{
'call_id': 'call-1',
'conversation_uuid': 'conv-1',
'status': 'accepted',
'accepted_device_id': 'device-android',
'accepted_client': 'android',
}),
),
);
await Future<void>.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<ChannelReadEvent>.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 = <String?>[];
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(<String, dynamic>{
'call_id': 'call-1',
'conversation_uuid': 'conv-1',
'status': 'accepted',
'accepted_device_id': 'device-iphone',
}),
),
);
await Future<void>.delayed(Duration.zero);
expect(dismissed, isEmpty);
await accepted.close();
},
);
});
}

View File

@@ -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));
},
);

View File

@@ -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);

View File

@@ -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<Exception>()),
);
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<String, dynamic>, 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<String, dynamic>;
expect(captured, <String, dynamic>{'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<String, dynamic>;
expect(captured, <String, dynamic>{
'hw_id': 'device-android-001',
'call_id': 'call-1',
});
});
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
const conversationUuid = 'conv-uuid-broken';

View File

@@ -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 = <String>[];
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 = <String>[];
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,

View File

@@ -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.')),

View File

@@ -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: <String, dynamic>{
'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: <String, dynamic>{'conversationUuid': conversation},
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
),
isTrue,
);
});
});
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
final calls = <MethodCall>[];
late List<Map<String, Object?>> activeCalls;
@@ -93,6 +140,40 @@ void main() {
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
});
test('ends only the matching call_id', () async {
activeCalls = <Map<String, Object?>>[
<String, Object?>{
'id': 'call-a',
'handle': conversation,
'extra': <String, Object?>{
'conversationUuid': conversation,
'call_id': 'call-a',
},
},
<String, Object?>{
'id': 'call-b',
'handle': conversation,
'extra': <String, Object?>{
'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, <String>['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, <String>['ringing-id']);
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
});
});
}

View File

@@ -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(

View File

@@ -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({

View File

@@ -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<VideoCallTokenResponse>());
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),

View File

@@ -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(<String, dynamic>{
'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(<String, dynamic>{
'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(<String, dynamic>{
'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(<String, dynamic>{
'status': 'declined',
}),
isNull,
);
});
}

View File

@@ -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,
);
});
}

View File

@@ -0,0 +1,6 @@
{
"sent": true,
"devices_notified": 1,
"call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b",
"status": "declined"
}