Merge pull request 'feat(video_calls): call accepting and declingn handling' (#1100) from misha/eatvio_app:issue-1089 into main

Reviewed-on: eatvio/eatvio_app#1100
This commit is contained in:
2026-10-23 11:13:50 +00:00
27 changed files with 960 additions and 171 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

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

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

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

@@ -33,6 +33,10 @@ import flutter_foreground_task
/// 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?
@@ -618,9 +622,12 @@ import flutter_foreground_task
}
}
/// Ends only the ringing CallKit call that matches `serverCallId` when the
/// backend sent one. Conversation fallback is legacy-only: a stale ended
/// push must not close a newer ring in the same chat.
/// 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,
@@ -628,6 +635,7 @@ import flutter_foreground_task
reason: String? = nil
) {
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
endReportedCallWithoutPlugin(reportedId)
releaseIncomingCallClaim(reportedId)
return
}
@@ -649,6 +657,7 @@ 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) ?? ""
@@ -660,18 +669,36 @@ import flutter_foreground_task
|| 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,
@@ -692,6 +719,7 @@ import flutter_foreground_task
endExtra["reason"] = reason
}
endData.extra = endExtra as NSDictionary
markRemoteEndedCallKitId(id)
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
@@ -714,24 +742,56 @@ import flutter_foreground_task
return nil
}
private func activeCallId(forConversation conversationUuid: String) -> String? {
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 handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
{
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] {
return typed
@@ -857,6 +917,16 @@ import flutter_foreground_task
{
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)
{
@@ -929,6 +999,7 @@ import flutter_foreground_task
let matchedActiveRing =
callKitIdSourceName == "active_call_id"
|| callKitIdSourceName == "active_conversation"
|| callKitIdSourceName == "active_legacy_ring"
let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming
let data = flutter_callkit_incoming.Data(
@@ -1022,8 +1093,8 @@ 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,
@@ -1031,25 +1102,30 @@ import flutter_foreground_task
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,
"call_id": serverCallId ?? "",
"reason": reason ?? "",
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
"endAfterReport": endAfterReport,
]
)
if Thread.isMainThread {
complete()
} else {
DispatchQueue.main.async(execute: complete)
}
finish()
}
if !endAfterReport {
@@ -1121,10 +1197,13 @@ import flutter_foreground_task
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
// Accept reports the CallKit payload on this channel, and that is what
// stops ringing on the other devices. The plugin decline event does not.
// 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] ?? [:]
if !extraMarksEndedPush(extra) {
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: [

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

@@ -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,12 +43,14 @@ 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');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
@@ -58,7 +60,15 @@ class WsManager {
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
/// 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;
@@ -94,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) {
@@ -115,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);
@@ -132,7 +138,8 @@ class WsManager {
await _initPrivateMessagesChannel();
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
if (data ==
PusherChannelsClientLifeCycleState.establishedConnection) {
handleConnectionEstablished();
}
});
@@ -174,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'},
),
);
}
@@ -187,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'},
),
);
}
@@ -201,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');
}
@@ -216,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'},
),
);
}
@@ -248,7 +261,8 @@ class WsManager {
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
(listener) =>
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
@@ -262,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);
@@ -288,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,6 +11,7 @@ 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';
@@ -26,10 +28,14 @@ class ChatsCubit extends Cubit<ChatsState> {
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;
@@ -80,6 +86,17 @@ class ChatsCubit extends Cubit<ChatsState> {
);
});
await videoCallAcceptedSubscription?.cancel();
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseEnded(event.data),
),
);
},
);
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
@@ -195,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;
@@ -242,6 +260,7 @@ class ChatsCubit extends Cubit<ChatsState> {
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
callId: callId,
);
} catch (e, st) {
await reportFrom(e, st, this);
@@ -252,6 +271,7 @@ 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) {
@@ -273,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));
@@ -289,6 +310,7 @@ 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) {
@@ -333,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,
@@ -363,6 +413,7 @@ class ChatsCubit extends Cubit<ChatsState> {
newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
videoCallAcceptedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}

View File

@@ -85,7 +85,6 @@ class VideoCallManager {
bool _forceEndRequested = false;
bool _sessionMayRequestToken = false;
String? _activeCallId;
final Set<String> _callDeclineNotifiedKeys = <String>{};
final Map<String, DateTime> _leftConversationAt = {};
final Set<String> _callAcceptNotifiedConversationUuids = {};
bool _callStageEntered = false;
@@ -519,49 +518,15 @@ class VideoCallManager {
}
}
/// Decline from the incoming UI before this device joins the room.
/// Drops local call state after the system decline button.
///
/// `POST /video-call/decline` stops ringing on the callee's other devices
/// and tells the caller the call was declined.
Future<void> declineIncomingCall(
String conversationUuid, {
String? callId,
}) async {
/// `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();
final resolvedCallId = backendVideoCallId(
callId: callId?.trim().isNotEmpty == true ? callId!.trim() : _activeCallId,
conversationUuid: uuid,
await _cleanupLocalCallAfterDecline(
conversationUuid: uuid.isEmpty ? null : uuid,
);
if (uuid.isEmpty) {
await _cleanupLocalCallAfterDecline();
return;
}
final declineKey = '${uuid.toLowerCase()}|${resolvedCallId ?? ''}';
final shouldNotify = incomingDeclineShouldNotifyBackend(
leftAt: _leftConversationAt[uuid],
now: DateTime.now(),
);
await _cleanupLocalCallAfterDecline(conversationUuid: uuid);
if (!shouldNotify || _callDeclineNotifiedKeys.contains(declineKey)) {
return;
}
final hwId = (await _resolveHwId())?.trim();
if (hwId == null || hwId.length < 8) return;
_callDeclineNotifiedKeys.add(declineKey);
try {
await _videoCallRepository.notifyCallDeclined(
conversationUuid: uuid,
hwId: hwId,
callId: resolvedCallId,
);
} catch (_) {
_callDeclineNotifiedKeys.remove(declineKey);
}
}
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
@@ -892,7 +857,6 @@ class VideoCallManager {
_roomConnectFuture = null;
_clearPreparedConnectionCredentials();
_callAcceptNotifiedConversationUuids.clear();
_callDeclineNotifiedKeys.clear();
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
}

View File

@@ -621,8 +621,8 @@ class VideoCallCallKitCoordinator {
);
}
/// Same payload accept uses. The plugin decline event is not what stops
/// ringing on the other devices.
/// 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;
@@ -651,7 +651,7 @@ class VideoCallCallKitCoordinator {
'VideoCallCallKitCoordinator: native declinedVoipCall conversation=$conversationUuid callId=$callId',
name: 'VideoCallCallKit',
);
await _manager.declineIncomingCall(conversationUuid, callId: callId);
await _manager.declineIncomingCall(conversationUuid);
}
Future<void> _handleAccept(String callKitId) async {
@@ -860,7 +860,7 @@ class VideoCallCallKitCoordinator {
final suppressedIds = <String>{
callKitId,
conversationUuid,
if (callId != null) callId,
?callId,
};
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
@@ -1031,11 +1031,6 @@ class VideoCallCallKitCoordinator {
extra,
handle: params?.handle ?? callKitId,
);
final callId = backendVideoCallId(
callId:
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null),
conversationUuid: conversationUuid,
);
final remoteEndDismissal =
callKitDeclineIsEndedPush(
cachedExtra: params?.extra,
@@ -1056,8 +1051,8 @@ class VideoCallCallKitCoordinator {
}
if (skipBackendNotify) {
// iOS decline is reported from AppDelegate with the CallKit payload,
// the same way accept is. This plugin event must not send /ended instead.
// AppDelegate already POSTed /decline. This plugin event must not send
// /ended for the same tap.
_forgetCallKitId(callKitId);
return;
}
@@ -1081,7 +1076,7 @@ class VideoCallCallKitCoordinator {
return;
}
await _manager.declineIncomingCall(conversationUuid, callId: callId);
await _manager.declineIncomingCall(conversationUuid);
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
await _cubit.onCallEndedExternally();
}

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

@@ -12,12 +12,16 @@ class VideoCallSocketSignal {
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);
@@ -54,6 +58,14 @@ class VideoCallSocketSignal {
conversationUuid: conversationUuid,
callId: callId,
reason: reason,
acceptedDeviceId: _string(data, const [
'accepted_device_id',
'acceptedDeviceId',
]),
acceptedClient: _string(data, const [
'accepted_client',
'acceptedClient',
]),
);
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.66+155
version: 1.3.67+156
publish_to: 'none'
environment:

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

@@ -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();
},
@@ -1063,6 +1069,84 @@ void main() {
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';
@@ -120,33 +119,17 @@ void main() {
);
test(
'decline of a call that was never joined notifies the backend via /decline',
'decline of a call that was never joined does not POST /decline from Dart',
() async {
when(
await manager.declineIncomingCall('other-uuid');
verifyNever(
mockRepository.notifyCallDeclined(
conversationUuid: anyNamed('conversationUuid'),
hwId: anyNamed('hwId'),
callId: anyNamed('callId'),
),
).thenAnswer(
(_) async => const VideoCallDeclineResponse(
sent: true,
devicesNotified: 1,
),
);
await manager.declineIncomingCall(
'other-uuid',
callId: 'call-uuid-1',
);
verify(
mockRepository.notifyCallDeclined(
conversationUuid: 'other-uuid',
hwId: 'test-hw-id-android-01',
callId: 'call-uuid-1',
),
).called(1);
verifyNever(mockRepository.notifyCallEnded(any));
},
);

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

@@ -34,15 +34,22 @@ void main() {
test('an accept marker does not tear down the call this device joined', () {
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
'call_id': 'call-1',
'reason': 'accepted',
'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'}),
VideoCallSocketSignal.tryParseDeclined(<String, dynamic>{
'status': 'declined',
}),
isNull,
);
});