forked from eatvio/eatvio_app
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:
@@ -39,7 +39,7 @@ android {
|
||||
versionName = flutter.versionName
|
||||
manifestPlaceholders = [
|
||||
YANDEX_CLIENT_ID: yandexClientId,
|
||||
applicationName: "io.flutter.app.FlutterApplication"
|
||||
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class EatvioApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
VideoCallDeclineReporter.install(this)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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\""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
149
ios/Runner/VideoCallDeclineClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ class AppConfig {
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ enum PrefsKeys {
|
||||
marketingBottomSheetHasBeenShown,
|
||||
isEmailVerificationSkipped,
|
||||
customBaseUrl,
|
||||
apiBaseUrl,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.')),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user