From 0ddd4c6b143f7dc0b26bf7783dcb1ac56293fb5b Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 23 Oct 2026 14:11:20 +0300 Subject: [PATCH 1/2] feat(video_calls): call accepting and declingn handling --- android/app/build.gradle | 2 +- .../com/eatvioapp/app/EatvioApplication.kt | 10 ++ .../eatvioapp/app/VideoCallDeclineClient.kt | 73 +++++++++ .../eatvioapp/app/VideoCallDeclineReporter.kt | 74 +++++++++ .../eatvioapp/app/VideoCallDeclineRequest.kt | 136 ++++++++++++++++ .../app/VideoCallDeclineRequestTest.kt | 103 ++++++++++++ .../reports/problems/problems-report.html | 2 +- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 145 +++++++++++++---- ios/Runner/VideoCallDeclineClient.swift | 149 ++++++++++++++++++ lib/core/app_config.dart | 1 + lib/core/cached_base_url.dart | 18 ++- lib/core/prefs_keys.dart | 1 + lib/core/websockets/ws_manager.dart | 131 ++++++++------- .../bloc/chats_cubit/chats_cubit.dart | 51 ++++++ .../managers/video_call_manager.dart | 48 +----- .../video_call_callkit_coordinator.dart | 19 +-- .../services/video_call_chat_signal.dart | 5 + .../services/video_call_socket_signal.dart | 12 ++ test/core/app_config_test.dart | 8 + test/core/enums/prefs_keys_test.dart | 4 +- test/core/prefs_keys_test.dart | 4 +- .../presentation/bloc/chats_cubit_test.dart | 84 ++++++++++ .../video_call_manager_lifecycle_test.dart | 25 +-- .../services/video_call_chat_signal_test.dart | 9 ++ .../video_call_socket_signal_test.dart | 11 +- 26 files changed, 959 insertions(+), 170 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt create mode 100644 android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt create mode 100644 ios/Runner/VideoCallDeclineClient.swift diff --git a/android/app/build.gradle b/android/app/build.gradle index 11428239..569cd689 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -39,7 +39,7 @@ android { versionName = flutter.versionName manifestPlaceholders = [ YANDEX_CLIENT_ID: yandexClientId, - applicationName: "io.flutter.app.FlutterApplication" + applicationName: "com.eatvioapp.app.EatvioApplication" ] } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt new file mode 100644 index 00000000..89b4b201 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioApplication.kt @@ -0,0 +1,10 @@ +package com.eatvioapp.app + +import android.app.Application + +class EatvioApplication : Application() { + override fun onCreate() { + super.onCreate() + VideoCallDeclineReporter.install(this) + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt new file mode 100644 index 00000000..7b7c1dce --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineClient.kt @@ -0,0 +1,73 @@ +package com.eatvioapp.app + +import android.content.Context +import android.util.Log +import java.net.HttpURLConnection +import java.net.URL + +internal object FlutterSharedPrefs { + const val NAME = "FlutterSharedPreferences" + private const val PREFIX = "flutter." + + fun string(context: Context, key: String): String? { + return context + .getSharedPreferences(NAME, Context.MODE_PRIVATE) + .getString(PREFIX + key, null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + } +} + +internal object VideoCallDeclineClient { + private const val TAG = "VideoCallDecline" + private const val TIMEOUT_MS = 4_000 + + fun send(context: Context, handle: String?, extra: Map<*, *>?) { + val fields = VideoCallDeclineRequestFactory.fields(handle, extra) + val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl") + ?: FlutterSharedPrefs.string(context, "customBaseUrl") + val request = VideoCallDeclineRequestFactory.build( + baseUrl = baseUrl, + token = FlutterSharedPrefs.string(context, "token"), + hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"), + fields = fields, + ) + if (request == null) { + Log.w( + TAG, + "skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}", + ) + return + } + + try { + val code = post(request) + Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}") + } catch (error: Exception) { + Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error) + } + } + + private fun post(request: VideoCallDeclineRequest): Int { + val connection = (URL(request.url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer ${request.token}") + setRequestProperty("Accept", "application/json") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + try { + connection.outputStream.use { stream -> + stream.write(request.body.toByteArray(Charsets.UTF_8)) + } + val code = connection.responseCode + val response = if (code in 200..299) connection.inputStream else connection.errorStream + response?.use { stream -> stream.readBytes() } + return code + } finally { + connection.disconnect() + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt new file mode 100644 index 00000000..130da466 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineReporter.kt @@ -0,0 +1,74 @@ +package com.eatvioapp.app + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.core.os.BundleCompat +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitEventCallback +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Sends `POST /video-call/decline` from the process that handled the tap. + * + * The notification Decline button is an explicit broadcast into the CallKit + * plugin. That broadcast starts the process when the app was killed and does + * not start Flutter. The plugin notifies this callback before it returns, so + * the request can finish while the process is still alive. + */ +object VideoCallDeclineReporter : CallkitEventCallback { + private const val TAG = "VideoCallDecline" + private const val WAIT_FOR_HTTP_MS = 9_000L + + @Volatile + private var appContext: Context? = null + + @Volatile + private var installed = false + + fun install(context: Context) { + if (installed) return + installed = true + appContext = context.applicationContext + FlutterCallkitIncomingPlugin.registerEventCallback(this) + Log.i(TAG, "installed") + } + + override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) { + if (event != CallkitEventCallback.CallEvent.DECLINE) return + val context = appContext ?: return + val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE) + val extra = copyExtra(callData) + if (MainActivity.isInForeground) { + CoroutineScope(Dispatchers.IO).launch { + VideoCallDeclineClient.send(context, handle, extra) + } + return + } + // The Decline button starts this process and then the broadcast ends. + // Wait here so Android does not kill the process before the POST returns. + runBlocking(Dispatchers.IO) { + withTimeoutOrNull(WAIT_FOR_HTTP_MS) { + VideoCallDeclineClient.send(context, handle, extra) + } + } + } + + private fun copyExtra(callData: Bundle): Map { + val raw = BundleCompat.getSerializable( + callData, + CallkitConstants.EXTRA_CALLKIT_EXTRA, + HashMap::class.java, + ) ?: return emptyMap() + return buildMap { + for ((key, value) in raw) { + if (key is String) put(key, value) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt new file mode 100644 index 00000000..6f851403 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallDeclineRequest.kt @@ -0,0 +1,136 @@ +package com.eatvioapp.app + +/** + * Body of `POST /api/v2/chat/{conversation}/video-call/decline`. + * + * [callId] is omitted when it is missing, not a uuid, or equal to the + * conversation uuid: the server treats `call_id` as the session uuid. + */ +internal data class VideoCallDeclineRequest( + val url: String, + val token: String, + val body: String, +) + +internal data class VideoCallDeclineFields( + val conversationUuid: String, + val callId: String?, + val endedPush: Boolean, +) + +internal object VideoCallDeclineRequestFactory { + private val HEX = "0123456789ABCDEF".toCharArray() + private val uuidPattern = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields { + val conversation = firstString( + extra, + listOf("conversationUuid", "conversation_uuid"), + ) ?: handle?.trim().orEmpty() + val rawCallId = firstString(extra, listOf("call_id", "callId")) + return VideoCallDeclineFields( + conversationUuid = conversation, + callId = backendCallId(rawCallId, conversation), + endedPush = marksEndedPush(extra), + ) + } + + fun build( + baseUrl: String?, + token: String?, + hwId: String?, + fields: VideoCallDeclineFields, + ): VideoCallDeclineRequest? { + if (fields.endedPush) return null + val conversation = fields.conversationUuid.trim() + val authToken = token?.trim().orEmpty() + val deviceId = hwId?.trim().orEmpty() + val root = baseUrl?.trim()?.trimEnd('/').orEmpty() + if (conversation.isEmpty() || authToken.isEmpty()) return null + if (deviceId.length < 8 || deviceId.length > 64) return null + if (!root.startsWith("http://") && !root.startsWith("https://")) return null + + val encoded = encodePathSegment(conversation) + return VideoCallDeclineRequest( + url = "$root/api/v2/chat/$encoded/video-call/decline", + token = authToken, + body = jsonBody(deviceId, fields.callId), + ) + } + + private fun backendCallId(callId: String?, conversationUuid: String): String? { + val id = callId?.trim().orEmpty() + if (!uuidPattern.matches(id)) return null + if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null + return id + } + + private fun firstString(extra: Map<*, *>?, keys: List): String? { + if (extra == null) return null + for (key in keys) { + val value = extra[key] as? String ?: continue + val trimmed = value.trim() + if (trimmed.isNotEmpty()) return trimmed + } + return null + } + + private fun marksEndedPush(extra: Map<*, *>?): Boolean { + val value = extra?.get("endedPush") ?: return false + return when (value) { + is Boolean -> value + is Number -> value.toInt() != 0 + is String -> { + val normalized = value.trim().lowercase() + normalized == "true" || normalized == "1" + } + else -> false + } + } + + private fun jsonBody(hwId: String, callId: String?): String { + val hw = jsonString(hwId) + if (callId == null) return """{"hw_id":$hw}""" + return """{"hw_id":$hw,"call_id":${jsonString(callId)}}""" + } + + private fun encodePathSegment(value: String): String { + val bytes = value.encodeToByteArray() + return buildString(bytes.size) { + for (byte in bytes) { + val code = byte.toInt() and 0xFF + val allowed = code in 'A'.code..'Z'.code || + code in 'a'.code..'z'.code || + code in '0'.code..'9'.code || + code == '-'.code || + code == '_'.code || + code == '.'.code || + code == '~'.code + if (allowed) { + append(code.toChar()) + } else { + append('%') + append(HEX[code ushr 4]) + append(HEX[code and 0x0F]) + } + } + } + } + + private fun jsonString(value: String): String { + val escaped = buildString { + for (char in value) { + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(char) + } + } + } + return "\"$escaped\"" + } +} diff --git a/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt b/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt new file mode 100644 index 00000000..e6e5397f --- /dev/null +++ b/android/app/src/test/kotlin/com/eatvioapp/app/VideoCallDeclineRequestTest.kt @@ -0,0 +1,103 @@ +package com.eatvioapp.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VideoCallDeclineRequestTest { + private val baseUrl = "https://eatvio.example" + private val token = "sanctum-token" + private val hwId = "install-hw-id-01" + private val conversation = "11111111-1111-1111-1111-111111111111" + private val callId = "22222222-2222-2222-2222-222222222222" + + @Test + fun buildsDeclinePostWithCallId() { + val request = request(callId = callId) + + assertEquals( + "https://eatvio.example/api/v2/chat/$conversation/video-call/decline", + request?.url, + ) + assertEquals(token, request?.token) + assertEquals( + """{"hw_id":"$hwId","call_id":"$callId"}""", + request?.body, + ) + } + + @Test + fun omitsCallIdWhenItIsTheConversationUuid() { + val request = request(callId = conversation.uppercase()) + + assertEquals("""{"hw_id":"$hwId"}""", request?.body) + } + + @Test + fun omitsCallIdThatIsNotAUuid() { + val request = request(callId = "message-15") + + assertEquals("""{"hw_id":"$hwId"}""", request?.body) + } + + @Test + fun readsConversationFromHandleWhenExtraHasNone() { + val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap()) + val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields) + + assertTrue(request?.url?.contains(conversation) == true) + } + + @Test + fun skipsEndedPush() { + val fields = VideoCallDeclineRequestFactory.fields( + conversation, + mapOf("conversationUuid" to conversation, "endedPush" to true), + ) + + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)) + } + + @Test + fun skipsMissingTokenBaseUrlOrShortHwId() { + val fields = VideoCallDeclineRequestFactory.fields( + null, + mapOf("conversationUuid" to conversation, "call_id" to callId), + ) + + assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields)) + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields)) + assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields)) + assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields)) + } + + @Test + fun trimsATrailingSlashOnTheBaseUrl() { + val request = VideoCallDeclineRequestFactory.build( + "$baseUrl/", + token, + hwId, + VideoCallDeclineRequestFactory.fields( + null, + mapOf("conversationUuid" to conversation), + ), + ) + + assertEquals( + "https://eatvio.example/api/v2/chat/$conversation/video-call/decline", + request?.url, + ) + } + + private fun request(callId: String?): VideoCallDeclineRequest? { + val fields = VideoCallDeclineRequestFactory.fields( + null, + mapOf( + "conversationUuid" to conversation, + "call_id" to callId, + ), + ) + return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields) + } +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index fa7c1a97..e7d78ded 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 15e4ffe4..76a8087f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; }; C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -38,6 +39,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = ""; }; C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; @@ -117,6 +119,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */, C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, FEE92A0E2D772EA300E6E989 /* Runner.entitlements */, @@ -303,6 +306,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */, C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bb34f583..488ea9c0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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() + 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: [ diff --git a/ios/Runner/VideoCallDeclineClient.swift b/ios/Runner/VideoCallDeclineClient.swift new file mode 100644 index 00000000..fb24ce50 --- /dev/null +++ b/ios/Runner/VideoCallDeclineClient.swift @@ -0,0 +1,149 @@ +import Foundation +import UIKit + +enum VideoCallDeclineClient { + private static let uuidPattern = + #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"# + + /// `POST /video-call/decline` from the CallKit callback. The process is + /// awake here even when the user never opens the app afterwards. + static func send(extra: [String: Any], handle: String) { + let fields = declineFields(extra: extra, handle: handle) + guard let request = makeRequest( + baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"), + token: prefsString("token"), + hwId: UIDevice.current.identifierForVendor?.uuidString, + fields: fields + ) else { + NSLog( + "[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" + ) + return + } + + let task = EatvioBackgroundTask() + task.begin(name: "video-call-decline") + let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + NSLog( + "[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" + ) + task.end() + } + urlTask.resume() + } + + static func makeRequest( + baseUrl: String?, + token: String?, + hwId: String?, + fields: VideoCallDeclineFields + ) -> URLRequest? { + if fields.endedPush { return nil } + let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + while root.hasSuffix("/") { + root.removeLast() + } + guard !conversation.isEmpty, !authToken.isEmpty else { return nil } + guard deviceId.count >= 8, deviceId.count <= 64 else { return nil } + guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil } + guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") + else { return nil } + + var body: [String: String] = ["hw_id": deviceId] + if let callId = fields.callId { + body["call_id"] = callId + } + guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 10 + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = data + return request + } + + private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields { + let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"]) + ?? handle.trimmingCharacters(in: .whitespacesAndNewlines) + let rawCallId = firstString(extra, keys: ["call_id", "callId"]) + return VideoCallDeclineFields( + conversationUuid: conversation, + callId: backendCallId(rawCallId, conversation: conversation), + endedPush: marksEndedPush(extra["endedPush"]) + ) + } + + private static func backendCallId(_ callId: String?, conversation: String) -> String? { + let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let regex = try? NSRegularExpression(pattern: uuidPattern), + regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil + else { return nil } + if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame { + return nil + } + return id + } + + private static func firstString(_ extra: [String: Any], keys: [String]) -> String? { + for key in keys { + guard let value = extra[key] as? String else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return nil + } + + private static func marksEndedPush(_ value: Any?) -> Bool { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.intValue != 0 } + if let text = value as? String { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "true" || normalized == "1" + } + return false + } + + private static func prefsString(_ key: String) -> String? { + let value = UserDefaults.standard.string(forKey: "flutter.\(key)")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, !value.isEmpty { return value } + return nil + } +} + +struct VideoCallDeclineFields { + var conversationUuid: String + var callId: String? + var endedPush: Bool +} + +private final class EatvioBackgroundTask { + private let lock = NSLock() + private var id: UIBackgroundTaskIdentifier = .invalid + + func begin(name: String) { + let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + self?.end() + } + lock.lock() + id = taskId + lock.unlock() + } + + func end() { + lock.lock() + let current = id + id = .invalid + lock.unlock() + guard current != .invalid else { return } + UIApplication.shared.endBackgroundTask(current) + } +} diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index 289d3d7b..921adaf5 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -48,6 +48,7 @@ class AppConfig { return; } unawaited(sl().setString(PrefsKeys.customBaseUrl, normalized)); + unawaited(sl().setString(PrefsKeys.apiBaseUrl, normalized)); } /// Список глобальных BlocProvider'ов diff --git a/lib/core/cached_base_url.dart b/lib/core/cached_base_url.dart index d6e82e73..1888d9f6 100644 --- a/lib/core/cached_base_url.dart +++ b/lib/core/cached_base_url.dart @@ -1,16 +1,30 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs_keys.dart'; /// Подставляет сохранённый base URL до создания HTTP-клиента. +/// +/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный +/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен. void applyCachedBaseUrl() { if (!sl.isRegistered() || !sl.isRegistered()) { return; } final cached = sl().getString(PrefsKeys.customBaseUrl)?.trim(); - if (cached == null || cached.isEmpty) { + if (cached != null && cached.isNotEmpty) { + sl().setBaseUrl(cached); return; } - sl().setBaseUrl(cached); + _persistApiBaseUrl(sl().baseUrl); +} + +void _persistApiBaseUrl(String baseUrl) { + final normalized = baseUrl.trim(); + if (normalized.isEmpty || !sl.isRegistered()) { + return; + } + unawaited(sl().setString(PrefsKeys.apiBaseUrl, normalized)); } diff --git a/lib/core/prefs_keys.dart b/lib/core/prefs_keys.dart index df74fa84..e0fbc272 100644 --- a/lib/core/prefs_keys.dart +++ b/lib/core/prefs_keys.dart @@ -5,4 +5,5 @@ enum PrefsKeys { marketingBottomSheetHasBeenShown, isEmailVerificationSkipped, customBaseUrl, + apiBaseUrl, } diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index b887aaf6..3ba31b8a 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -18,7 +18,7 @@ class WsManager { final ErrorReporter _errorReporter; WsManager({required this.wsService, required ErrorReporter errorReporter}) - : _errorReporter = errorReporter; + : _errorReporter = errorReporter; PusherChannelsClient? client; int? userId; @@ -43,12 +43,14 @@ class WsManager { /// Контроллер создаётся один раз и не закрывается в [clear]: подписчики /// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать /// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize]. - StreamController _reconnectController = StreamController.broadcast(); + StreamController _reconnectController = + StreamController.broadcast(); Stream? _directChatStream; Stream? _clientTypingStream; - Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); + Stream? get newMessagesStream => + _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); /// The callee rejected the ring. Broadcast to the caller and the callee. Stream? get videoCallDeclinedStream => @@ -58,7 +60,15 @@ class WsManager { Stream? get videoCallEndedStream => _privateMessagesChannel?.bind('video-call-ended'); - Stream? 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? get videoCallAcceptedStream => + _privateMessagesChannel?.bind('video-call-accepted'); + + Stream? get newNotificationsStream => + _privateNotificationChannel?.bind( + 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', + ); Stream? 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().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().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().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().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().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), + ); + _directChatStream = _privateDirectChatChannel?.bind( + 'App\\Events\\ChatMessageUpdated', ); - _directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); _clientTypingStream = _privateDirectChatChannel?.bind('client-typing'); } @@ -216,12 +228,13 @@ class WsManager { final token = await TokenStorage.getTokenFromStorage(); _presenceDirectChatChannel = client?.presenceChannel( '$presenceDirectChatChannelName.$chatId', - authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( - authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), - headers: { - 'Authorization': 'Bearer $token', - }, - ), + authorizationDelegate: + EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( + authorizationEndpoint: Uri.parse( + '${sl().baseUrl}/broadcasting/auth', + ), + headers: {'Authorization': 'Bearer $token'}, + ), ); } @@ -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); diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index b5b76ccd..499766f6 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; import 'package:eatvio_app/core/enums/base_state.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; @@ -10,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 { StreamSubscription? reconnectedSubscription; StreamSubscription? videoCallDeclinedSubscription; StreamSubscription? videoCallEndedSubscription; + StreamSubscription? videoCallAcceptedSubscription; final Set _presentedVideoCallMessageIds = {}; /// Test seam. Production uses CallKit. Future Function(VideoCallPushPayload payload)? presentIncomingVideoCall; + + /// Test seam. Production reads [DeviceHwIdProvider.current]. + Future Function()? currentDeviceHwId; Future Function(String? conversationUuid)? dismissIncomingVideoCall; Future Function({String? conversationUuid, String? callId})? endActiveVideoCall; @@ -80,6 +86,17 @@ class ChatsCubit extends Cubit { ); }); + 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 { try { final json = jsonDecode(data) as Map; final message = ChatMessageModel.fromJson(json); + final callId = _stringField(json, const ['call_id', 'callId']); final currentUserId = wsManager.userId; final isOwn = currentUserId != null && message.authorId == currentUserId; @@ -242,6 +260,7 @@ class ChatsCubit extends Cubit { message, isOwn: isOwn, chatRooms: currentChatRooms, + callId: callId, ); } catch (e, st) { await reportFrom(e, st, this); @@ -252,6 +271,7 @@ class ChatsCubit extends Cubit { ChatMessageModel message, { required bool isOwn, required List chatRooms, + String? callId, }) { final kind = VideoCallChatSignal.kindOf(message); if (kind == VideoCallChatSignalKind.ended) { @@ -273,6 +293,7 @@ class ChatsCubit extends Cubit { 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 { Future _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 { } } + /// `video-call-accepted` also reaches the device that just answered. + /// Closing CallKit there hangs up the call that is starting. + Future _acceptedOnThisDevice(VideoCallSocketSignal signal) async { + if (signal.reason != VideoCallEndReason.accepted) return false; + final acceptedId = signal.acceptedDeviceId?.trim(); + if (acceptedId == null || acceptedId.isEmpty) return false; + try { + final current = + (await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim(); + if (current == null || current.isEmpty) return false; + return current.toLowerCase() == acceptedId.toLowerCase(); + } catch (e, st) { + log( + 'ChatsCubit: failed to read hw id for accepted call: $e', + stackTrace: st, + ); + return false; + } + } + + String? _stringField(Map json, List keys) { + for (final key in keys) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + } + return null; + } + ChatUser? _callerFromRooms( ChatMessageModel message, List chatRooms, @@ -363,6 +413,7 @@ class ChatsCubit extends Cubit { newIncomingMessagesSubscription?.cancel(); videoCallDeclinedSubscription?.cancel(); videoCallEndedSubscription?.cancel(); + videoCallAcceptedSubscription?.cancel(); reconnectedSubscription?.cancel(); return super.close(); } diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index ff531fc2..ff814360 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -85,7 +85,6 @@ class VideoCallManager { bool _forceEndRequested = false; bool _sessionMayRequestToken = false; String? _activeCallId; - final Set _callDeclineNotifiedKeys = {}; final Map _leftConversationAt = {}; final Set _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 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 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 _cleanupLocalCallAfterDecline({String? conversationUuid}) async { @@ -892,7 +857,6 @@ class VideoCallManager { _roomConnectFuture = null; _clearPreparedConnectionCredentials(); _callAcceptNotifiedConversationUuids.clear(); - _callDeclineNotifiedKeys.clear(); _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false)); } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index fb0157ec..5d9b456a 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -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 _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 _handleAccept(String callKitId) async { @@ -860,7 +860,7 @@ class VideoCallCallKitCoordinator { final suppressedIds = { callKitId, conversationUuid, - if (callId != null) callId, + ?callId, }; unawaited( Future.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(); } diff --git a/lib/features/video_calls/services/video_call_chat_signal.dart b/lib/features/video_calls/services/video_call_chat_signal.dart index 7e0f22b3..8327ae97 100644 --- a/lib/features/video_calls/services/video_call_chat_signal.dart +++ b/lib/features/video_calls/services/video_call_chat_signal.dart @@ -57,6 +57,7 @@ abstract final class VideoCallChatSignal { required ChatMessageModel message, String? callerName, String? callerAvatarUrl, + String? callId, }) { if (kindOf(message) != VideoCallChatSignalKind.started) return null; final conversationUuid = message.roomId?.trim(); @@ -64,6 +65,7 @@ abstract final class VideoCallChatSignal { final resolvedName = callerName?.trim(); final avatar = callerAvatarUrl?.trim(); + final serverCallId = callId?.trim(); return VideoCallPushPayload( conversationUuid: conversationUuid, counterpartName: resolvedName != null && resolvedName.isNotEmpty @@ -71,6 +73,9 @@ abstract final class VideoCallChatSignal { : callerNameFromBody(message.data), counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null, messageId: message.id > 0 ? message.id : null, + callId: serverCallId != null && serverCallId.isNotEmpty + ? serverCallId + : null, ); } diff --git a/lib/features/video_calls/services/video_call_socket_signal.dart b/lib/features/video_calls/services/video_call_socket_signal.dart index 1978208f..0ad5a8ad 100644 --- a/lib/features/video_calls/services/video_call_socket_signal.dart +++ b/lib/features/video_calls/services/video_call_socket_signal.dart @@ -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', + ]), ); } diff --git a/test/core/app_config_test.dart b/test/core/app_config_test.dart index 9f72563d..4aa8dc37 100644 --- a/test/core/app_config_test.dart +++ b/test/core/app_config_test.dart @@ -111,6 +111,7 @@ void main() { prefs.getString(PrefsKeys.customBaseUrl), 'https://custom.example', ); + expect(prefs.getString(PrefsKeys.apiBaseUrl), 'https://custom.example'); }); test('setBaseUrl removes cached override when url is blank', () async { @@ -145,9 +146,14 @@ void main() { ); applyCachedBaseUrl(); + await Future.delayed(Duration.zero); expect(sl().baseUrl, 'https://cached.example'); expect(sl().options.baseUrl, 'https://cached.example'); + expect( + sl().getString(PrefsKeys.apiBaseUrl), + 'https://cached.example', + ); }, ); @@ -162,9 +168,11 @@ void main() { ); applyCachedBaseUrl(); + await Future.delayed(Duration.zero); expect(sl().baseUrl, EnvConfig.baseUrl); expect(sl().options.baseUrl, EnvConfig.baseUrl); + expect(sl().getString(PrefsKeys.apiBaseUrl), EnvConfig.baseUrl); }); test('setBaseUrl can be called multiple times', () { diff --git a/test/core/enums/prefs_keys_test.dart b/test/core/enums/prefs_keys_test.dart index fe2f15cc..bb77e7cb 100644 --- a/test/core/enums/prefs_keys_test.dart +++ b/test/core/enums/prefs_keys_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('has all expected values', () { - expect(PrefsKeys.values.length, 6); + expect(PrefsKeys.values.length, 7); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); @@ -14,6 +14,7 @@ void main() { ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); + expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl)); }); test('can be compared', () { @@ -44,6 +45,7 @@ void main() { 'isEmailVerificationSkipped', ); expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); + expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl'); }); }); } diff --git a/test/core/prefs_keys_test.dart b/test/core/prefs_keys_test.dart index 98785895..34578946 100644 --- a/test/core/prefs_keys_test.dart +++ b/test/core/prefs_keys_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('все значения enum определены', () { - expect(PrefsKeys.values.length, 6); + expect(PrefsKeys.values.length, 7); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); @@ -14,6 +14,7 @@ void main() { ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); + expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl)); }); test('имена ключей корректны', () { @@ -32,6 +33,7 @@ void main() { 'isEmailVerificationSkipped', ); expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); + expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl'); }); test('can be compared', () { diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 079ca3c1..5ad5c65a 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -89,6 +89,7 @@ void main() { required int authorId, required String roomId, String data = 'socket message', + String? callId, }) { return _FakeChannelReadEvent( jsonEncode({ @@ -100,6 +101,7 @@ void main() { 'read_by_to_user': false, 'is_author_message_auth_user': false, 'room_id': roomId, + if (callId != null) 'call_id': callId, }), ); } @@ -418,8 +420,10 @@ void main() { () async { const chatId = 'chat-id'; final presented = []; + String? presentedCallId; chatsCubit.presentIncomingVideoCall = (payload) async { presented.add(payload.conversationUuid); + presentedCallId = payload.callId; }; chatsCubit.emit( ChatsState( @@ -436,11 +440,13 @@ void main() { authorId: 7, roomId: chatId, data: 'Анна начал(а) видеозвонок.', + callId: '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff', ), ); await Future.delayed(const Duration(milliseconds: 100)); expect(presented, [chatId]); + expect(presentedCallId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff'); await controller.close(); }, @@ -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.broadcast(); + when(mockWsManager.newMessagesStream).thenReturn(null); + when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty()); + when(mockWsManager.videoCallDeclinedStream).thenReturn(null); + when(mockWsManager.videoCallEndedStream).thenReturn(null); + when( + mockWsManager.videoCallAcceptedStream, + ).thenAnswer((_) => accepted.stream); + chatsCubit.currentDeviceHwId = () async => 'device-iphone'; + + final dismissed = []; + final ended = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async { + ended.add(conversationUuid); + }; + + await chatsCubit.connectToWebSocket(); + accepted.add( + _FakeChannelReadEvent( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'accepted', + 'accepted_device_id': 'device-android', + 'accepted_client': 'android', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, ['conv-1']); + expect(ended, isEmpty); + await accepted.close(); + }); + + test( + 'video-call-accepted keeps the call on the device that answered', + () async { + final accepted = StreamController.broadcast(); + when(mockWsManager.newMessagesStream).thenReturn(null); + when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty()); + when(mockWsManager.videoCallDeclinedStream).thenReturn(null); + when(mockWsManager.videoCallEndedStream).thenReturn(null); + when( + mockWsManager.videoCallAcceptedStream, + ).thenAnswer((_) => accepted.stream); + chatsCubit.currentDeviceHwId = () async => 'Device-iPhone'; + + final dismissed = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async { + fail('accepted call must stay on the answering device'); + }; + + await chatsCubit.connectToWebSocket(); + accepted.add( + _FakeChannelReadEvent( + jsonEncode({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'accepted', + 'accepted_device_id': 'device-iphone', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, isEmpty); + await accepted.close(); + }, + ); }); } diff --git a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart index fe89ef8e..64648b8f 100644 --- a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart +++ b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart @@ -1,5 +1,4 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; @@ -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)); }, ); diff --git a/test/features/video_calls/services/video_call_chat_signal_test.dart b/test/features/video_calls/services/video_call_chat_signal_test.dart index a34d6529..7eb553f1 100644 --- a/test/features/video_calls/services/video_call_chat_signal_test.dart +++ b/test/features/video_calls/services/video_call_chat_signal_test.dart @@ -33,6 +33,15 @@ void main() { expect(payload.messageId, 1); }); + test('keeps the server call id on the incoming payload', () { + final payload = VideoCallChatSignal.incomingPayload( + message: message(data: 'Анна начал(а) видеозвонок.'), + callId: ' 6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff ', + ); + + expect(payload?.callId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff'); + }); + test('recognizes an english realtime video call start', () { expect( VideoCallChatSignal.kindOf(message(data: 'Anna started a video call.')), diff --git a/test/features/video_calls/services/video_call_socket_signal_test.dart b/test/features/video_calls/services/video_call_socket_signal_test.dart index 889b44c6..f91b6c4b 100644 --- a/test/features/video_calls/services/video_call_socket_signal_test.dart +++ b/test/features/video_calls/services/video_call_socket_signal_test.dart @@ -34,15 +34,22 @@ void main() { test('an accept marker does not tear down the call this device joined', () { final signal = VideoCallSocketSignal.tryParseEnded({ '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({'status': 'declined'}), + VideoCallSocketSignal.tryParseDeclined({ + 'status': 'declined', + }), isNull, ); }); From d30c63be852fc581025fefc2892a429e74545347 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 23 Oct 2026 14:11:52 +0300 Subject: [PATCH 2/2] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index aea7a6ae..70b070f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: