Compare commits

...

25 Commits

Author SHA1 Message Date
misha
3122394037 feat(video_calls): ios calle mic fix 2026-10-21 17:48:53 +03:00
misha
9916828956 feat(video_calls): ios mic fix 2026-10-21 17:11:25 +03:00
misha
8cb3a184b0 feat(video_calls): ios mic fix, tokens clearing fix 2026-10-21 16:24:23 +03:00
misha
592f387221 Merge remote-tracking branch 'upstream/main' into issue-1089 2026-10-21 15:03:59 +03:00
misha
dcf695777a version changed 2026-10-21 14:58:11 +03:00
misha
141cf5543d feat(video_calls): continous ringing on a defferent device fix 2026-10-21 14:25:59 +03:00
40ba5f304e Merge pull request 'fix(verification): show email confirmation when 405 reports missing email' (#1088) from apoloz/eatvio_app:fix/1087-email-verification-channel into main
Reviewed-on: eatvio/eatvio_app#1088
Reviewed-by: misha <misha@noreply.localhost>
2026-10-20 11:42:45 +00:00
Anatoliy Poloz
996cf07fb8 merege from main + version bump 2026-10-20 13:05:57 +03:00
Anatoliy Poloz
1766314bbe bump version 2026-10-20 13:03:23 +03:00
d1575d7ac4 fix(verification): show email confirmation when 405 reports missing email (#1087) 2026-10-19 18:11:34 +03:00
94bca6a793 Merge pull request 'feat(video_calls): screen waking up' (#1083) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1083
2026-10-19 13:15:30 +00:00
misha
a6b94f835c feat(video_calls): ios voip push handling fix 2026-10-19 16:09:20 +03:00
misha
4c38026748 Merge remote-tracking branch 'upstream/main' 2026-10-19 12:50:07 +03:00
misha
355ec2df48 feat(video_calls): added phone waking up after it gets call push 2026-10-19 12:49:09 +03:00
04195dd95b Merge pull request 'bump version for TF' (#1082) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1082
2026-10-19 07:55:43 +00:00
Anatoliy Poloz
d3336b1263 bump version for TF 2026-10-19 10:48:32 +03:00
82f5c9a7fe Merge pull request 'fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown' (#1081) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1081
Reviewed-by: misha <misha@noreply.localhost>
2026-10-19 07:32:51 +00:00
Anatoliy Poloz
39a512193e fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown 2026-10-16 21:12:40 +03:00
4dc5839317 Merge pull request 'feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix' (#1079) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1079
2026-10-16 15:21:13 +00:00
misha
da6158625f fix: tests 2026-10-16 16:30:56 +03:00
misha
5a8f8aee3e feat(video_calls): ios repeated call fix 2026-10-16 15:41:13 +03:00
misha
86e9d0cc31 feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix 2026-10-16 15:40:41 +03:00
60080c7407 Merge pull request 'feat(video_calls): ios voip push, mix and crushes fix' (#1078) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1078
2026-10-16 10:57:37 +00:00
293601f1e1 Merge pull request 'feat(ios): env migration for ios 27' (#1077) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1077
2026-10-16 05:27:39 +00:00
6a259e5452 Merge pull request 'feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching' (#1076) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1076
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-15 13:32:29 +00:00
49 changed files with 2628 additions and 271 deletions

View File

@@ -87,8 +87,11 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
Replace it with a bridge that routes to the same accept flow as Answer. -->
<!-- The incoming notification's content intent and full-screen intent both open
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
routes to the same accept flow as Answer; on a locked/dark screen it shows a
ringing screen that wakes the device. Own task affinity keeps the plugin's
CLEAR_TASK flag from killing MainActivity. -->
<activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" />
@@ -97,20 +100,26 @@
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:configChanges="orientation" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>

View File

@@ -1,12 +1,71 @@
package com.eatvioapp.app
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
import com.hiennv.flutter_callkit_incoming.Utils
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
import java.lang.ref.WeakReference
import kotlin.math.abs
import com.hiennv.flutter_callkit_incoming.R as CallkitR
/// Redirects notification body / full-screen intent taps to the same accept path as
/// the Answer button, instead of showing the native Callkit incoming UI.
/// Target of the incoming notification's content intent and full-screen intent.
///
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
/// accept flow as the Answer button.
///
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
/// ringing screen that wakes the device and lets the user answer or decline.
class EatvioCallkitIncomingBridgeActivity : Activity() {
companion object {
private const val TAG = "EatvioIncomingBridge"
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
private const val DEFAULT_RING_DURATION_MS = 45_000L
private const val MIN_RING_SCREEN_MS = 1_000L
private val mainHandler = Handler(Looper.getMainLooper())
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
/**
* Closes the ringing screen once the call stopped ringing: answered or declined
* from the notification, timed out, or ended by the caller.
*/
fun dismissRingingScreen() {
mainHandler.post {
ringingScreen?.get()?.closeRingingScreen()
ringingScreen = null
}
}
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
return keyguardLocked || !screenInteractive
}
}
private var callData: Bundle? = null
private var wakeLock: PowerManager.WakeLock? = null
private var ringTimeout: Runnable? = null
private var actionTaken = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
finish()
return
}
callData = data
CallKitIntentBridge.dispatchCallKitAction(
this,
CallkitConstants.ACTION_CALL_ACCEPT,
data,
if (!launchedOnLockedOrDarkScreen()) {
Log.i(TAG, "tap on notification body: accept")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
return
}
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
showRingingScreen(data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
if (ringingScreen?.get() !== this) return
callData = data
bindCallData(data)
scheduleRingTimeout(data)
}
override fun onDestroy() {
cancelRingTimeout()
releaseWakeLock()
if (ringingScreen?.get() === this) {
ringingScreen = null
}
super.onDestroy()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// The ringing screen is dismissed by Answer / Decline / timeout only.
}
private fun launchedOnLockedOrDarkScreen(): Boolean {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return shouldShowRingingScreen(
keyguardLocked = keyguardManager.isKeyguardLocked,
screenInteractive = powerManager.isInteractive,
)
}
private fun showRingingScreen(data: Bundle) {
ringingScreen = WeakReference(this)
setTheme(CallkitR.style.CallkitIncomingTheme)
requestedOrientation = if (Utils.isTablet(this)) {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true)
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
acquireWakeLock(ringDuration(data))
VideoCallPushBridge.keepRingingOnFullScreen()
setContentView(CallkitR.layout.activity_callkit_incoming)
bindCallData(data)
scheduleRingTimeout(data)
}
private fun bindCallData(data: Bundle) {
val background: ImageView = findViewById(CallkitR.id.ivBackground)
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
ripple.startRippleAnimation()
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
actions.layoutParams = params
}
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
callerNumber.visibility = View.INVISIBLE
logo.visibility = View.INVISIBLE
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
callerName.setTextColor(textColor)
acceptLabel.setTextColor(textColor)
declineLabel.setTextColor(textColor)
background.setBackgroundColor(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
if (!avatarUrl.isNullOrEmpty()) {
avatar.visibility = View.VISIBLE
@Suppress("DEPRECATION", "UNCHECKED_CAST")
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
try {
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
} catch (error: Exception) {
Log.w(TAG, "failed to load caller avatar", error)
}
}
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
acceptButton.setImageResource(CallkitR.drawable.ic_video)
}
acceptButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
)
declineButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
)
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
acceptButton.setOnClickListener { onAcceptClick() }
declineButton.setOnClickListener { onDeclineClick() }
}
private fun onAcceptClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: accept")
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
// becomes visible as soon as the user unlocks.
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
keyguardManager.requestDismissKeyguard(this, null)
}
}
finishRingingTask()
}
private fun onDeclineClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: decline")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
finishRingingTask()
}
private fun closeRingingScreen() {
if (isFinishing) return
cancelRingTimeout()
finishRingingTask()
}
private fun finishRingingTask() {
if (isFinishing) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask()
} else {
finish()
}
}
private fun ringDuration(data: Bundle): Long {
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
}
/**
* The notification was posted before this screen appeared, so ring only for the
* remaining part of the call duration.
*/
private fun scheduleRingTimeout(data: Bundle) {
cancelRingTimeout()
val now = System.currentTimeMillis()
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
val timeout = Runnable {
Log.i(TAG, "ringing screen: timed out")
closeRingingScreen()
}
ringTimeout = timeout
mainHandler.postDelayed(timeout, remaining)
}
private fun cancelRingTimeout() {
ringTimeout?.let { mainHandler.removeCallbacks(it) }
ringTimeout = null
}
@Suppress("DEPRECATION")
private fun acquireWakeLock(durationMs: Long) {
try {
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
wakeLock = lock
} catch (error: Exception) {
Log.w(TAG, "failed to acquire wake lock", error)
}
}
private fun releaseWakeLock() {
val lock = wakeLock ?: return
wakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "failed to release wake lock", error)
}
}
private fun parseColor(value: String?, fallback: Int): Int {
if (value.isNullOrBlank()) return fallback
return try {
Color.parseColor(value)
} catch (error: IllegalArgumentException) {
fallback
}
}
}

View File

@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
return
}

View File

@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
if (message == null) return
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
return
}

View File

@@ -59,12 +59,36 @@ class MainActivity: FlutterActivity() {
when (call.method) {
"dismissIncomingCalls" -> {
try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
val args = call.arguments as? Map<*, *>
val callId = args?.get("call_id")?.toString()
?: args?.get("callId")?.toString()
val conversationUuid = args?.get("conversation_uuid")?.toString()
?: args?.get("conversationUuid")?.toString()
VideoCallPushBridge.dismissIncomingCalls(
applicationContext,
callId = callId,
conversationUuid = conversationUuid,
)
result.success(null)
} catch (e: Exception) {
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
}
}
"showIncomingCall" -> {
try {
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
val data = HashMap<String, String>()
for ((key, value) in args) {
val name = key as? String ?: continue
if (value == null) continue
data[name] = value.toString()
}
VideoCallPushBridge.showIncomingCall(applicationContext, data)
result.success(null)
} catch (e: Exception) {
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
}
}
"drainPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.drain())
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -17,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -24,16 +26,20 @@ object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = null
private var ringWakeLock: PowerManager.WakeLock? = null
private var callbackRegistered = false
/**
@@ -115,23 +121,38 @@ object VideoCallPushBridge {
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
val generation = playbackGeneration.incrementAndGet()
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
val seenGeneration = playbackGeneration.get()
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
// A second show for the same conversation must not bump the generation:
// that would cancel the timeout of the call that is already ringing.
mainHandler.post {
synchronized(playbackLock) {
if (generation != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
if (seenGeneration != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped after silence")
return@post
}
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
Log.i(TAG, "showIncomingCall: already ringing $callId")
return@post
}
val generation = playbackGeneration.incrementAndGet()
startIncomingCallLocked(appContext, callData, generation)
}
}
@@ -158,17 +179,53 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
/**
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
* the display on itself, so keep ringing through that transition.
*/
fun keepRingingOnFullScreen() {
synchronized(playbackLock) {
activeSoundManager?.keepRingingOnFullScreen()
}
}
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
stopPlaybackLocked(appContext)
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
@@ -176,25 +233,39 @@ object VideoCallPushBridge {
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
stopIncomingRingtone()
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
@@ -206,6 +277,18 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
@@ -230,6 +313,7 @@ object VideoCallPushBridge {
}
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
if (durationMs > 0L) {
val timeout = Runnable {
synchronized(playbackLock) {
@@ -243,8 +327,43 @@ object VideoCallPushBridge {
}
}
/**
* Turns the screen on while the call rings. The full-screen intent of the
* notification does the same only when the system grants it; the wake lock
* works without any grant, so the lock screen with the call card lights up.
*/
@Suppress("DEPRECATION")
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
releaseScreenWakeLockLocked()
try {
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
ringWakeLock = lock
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
}
}
private fun releaseScreenWakeLockLocked() {
val lock = ringWakeLock ?: return
ringWakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
}
}
private fun stopPlaybackLocked(appContext: Context) {
cancelRingtoneTimeoutLocked()
releaseScreenWakeLockLocked()
val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
@@ -258,6 +377,7 @@ object VideoCallPushBridge {
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
@@ -344,6 +464,8 @@ object VideoCallPushBridge {
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null
val serverCallId = callId(data)
val callKitId = serverCallId ?: conversationUuid
val callerName = data["counterpartName"]
?: data["counterpart_name"]
?: data["caller_name"]
@@ -369,6 +491,9 @@ object VideoCallPushBridge {
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
@@ -379,9 +504,13 @@ object VideoCallPushBridge {
if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true
}
val messageId = data["message_id"] ?: data["messageId"]
if (!messageId.isNullOrBlank()) {
extra["message_id"] = messageId
}
return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
@@ -406,6 +535,74 @@ object VideoCallPushBridge {
}
}
private fun callId(data: Map<String, String>): String? {
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
}
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
}
private fun bundleMatches(
bundle: Bundle,
callId: String?,
conversationUuid: String?,
): Boolean {
val normalizedCallId = callId?.trim()?.lowercase()
if (!normalizedCallId.isNullOrEmpty()) {
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
if (id != null && id == normalizedCallId) return true
val extraCallId = extraString(bundle, "call_id")
?: extraString(bundle, "callId")
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
if (handle != null && handle == normalizedConversation) return true
val extraConversation = extraString(bundle, "conversationUuid")
?: extraString(bundle, "conversation_uuid")
return extraConversation?.lowercase() == normalizedConversation
}
@Suppress("UNCHECKED_CAST")
private fun extraString(bundle: Bundle, key: String): String? {
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
?: return null
val value = extra[key] ?: return null
val text = value.toString().trim()
return text.takeIf { it.isNotEmpty() }
}
private fun markTombstone(callId: String) {
val normalized = callId.trim().lowercase()
if (normalized.isEmpty()) return
pruneTombstones()
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
}
private fun isTombstoned(callId: String?): Boolean {
val normalized = callId?.trim()?.lowercase() ?: return false
if (normalized.isEmpty()) return false
pruneTombstones()
return tombstonedCallIds.containsKey(normalized)
}
private fun pruneTombstones() {
val now = System.currentTimeMillis()
val iterator = tombstonedCallIds.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value <= now) {
iterator.remove()
}
}
}
private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]

View File

@@ -15,11 +15,13 @@ import flutter_foreground_task
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
private let pendingAcceptedCallTtl: TimeInterval = 120
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private let appUrlScheme = "eatvio"
/// CallKit is only the ringing UI. After accept the in-app call continues,
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
/// True only if CallKit ended while the in-app call must keep audio.
/// Incoming accept keeps CallKit up, so this stays false after answer.
private var resumeAudioAfterCallKitEnds = false
/// The push handler and the Flutter channel touch this map on different
/// queues. The push writes the id before CallKit has the call, so the
@@ -27,9 +29,16 @@ import flutter_foreground_task
private var reservedIncomingCallIds: [String: Date] = [:]
private let incomingCallClaimLock = NSLock()
private let incomingCallClaimTtl: TimeInterval = 90
/// Cancelled / declined server call ids. A late `video_call_started` with the
/// same id must not show ringing again (same TTL as Android / Dart).
private var tombstonedCallIds: [String: Date] = [:]
private let callTombstoneTtl: TimeInterval = 120
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
private var voipCallReporterStorage: VoipCallReporter?
private var audioRestartGeneration = 0
/// CallKit owns AVAudioSession from answer until the CallKit call ends.
/// WebRTC must not call setActive(true) on top of that session.
private var callKitOwnsAudioSession = false
private var voipCallReporter: VoipCallReporter {
if let voipCallReporterStorage {
@@ -185,14 +194,19 @@ import flutter_foreground_task
]
)
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
var extra: [String: Any] = ["conversationUuid": conversationUuid]
if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value {
let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines)
if !serverCallId.isEmpty {
extra["call_id"] = serverCallId
}
}
voipDiagnosticsChannel?.invokeMethod(
"acceptedVoipCall",
arguments: [
"id": callKitId,
"handle": conversationUuid,
"extra": [
"conversationUuid": conversationUuid
],
"extra": extra,
]
)
}
@@ -221,6 +235,78 @@ import flutter_foreground_task
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
}
/// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived
/// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`).
private func resolveCallKitId(
serverCallId: String?,
conversationUuid: String,
messageId: UInt64?
) -> (id: String, source: String) {
if let serverCallId, !serverCallId.isEmpty {
return (serverCallId, "call_id")
}
if let derived = derivedCallKitId(
conversationUuid: conversationUuid,
messageId: messageId
) {
return (derived, "conversation_and_message")
}
if UUID(uuidString: conversationUuid) != nil {
return (conversationUuid.lowercased(), "conversation")
}
return (UUID().uuidString.lowercased(), "generated")
}
private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? {
for key in ["call_id", "callId"] {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return nil
}
private func voipEndReason(from dict: [AnyHashable: Any]) -> String? {
for key in ["reason", "end_reason", "endReason"] {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed.lowercased()
}
}
}
return nil
}
private func pruneCallTombstones(now: Date = Date()) {
tombstonedCallIds = tombstonedCallIds.filter {
now.timeIntervalSince($0.value) < callTombstoneTtl
}
}
private func markCallTombstone(_ callId: String?) {
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
guard !normalized.isEmpty else { return }
pruneCallTombstones()
tombstonedCallIds[normalized] = Date()
DispatchQueue.main.async { [weak self] in
self?.voipDiagnosticsChannel?.invokeMethod(
"tombstoneCallId",
arguments: ["id": normalized]
)
}
}
private func isCallTombstoned(_ callId: String?) -> Bool {
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
guard !normalized.isEmpty else { return false }
pruneCallTombstones()
return tombstonedCallIds[normalized] != nil
}
private func voipMessageId(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber {
let parsed = number.uint64Value
@@ -234,6 +320,26 @@ import flutter_foreground_task
return nil
}
private func voipBool(_ 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 {
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes":
return true
case "0", "false", "no":
return false
default:
return nil
}
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -282,16 +388,31 @@ import flutter_foreground_task
}
private func storePendingAcceptedCall(_ call: Call) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
/// An accept that Flutter never consumed (the process died before the
/// scene opened) must not open the call screen on a later launch: that
/// screen requests a token, and the token request rings the other person.
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
recordVoipDiagnostic(
"pending_accepted_call_expired",
data: [
"callKitId": payload["id"] as? String ?? "",
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
]
)
return nil
}
return payload
}
@@ -319,10 +440,15 @@ import flutter_foreground_task
var components = URLComponents()
components.scheme = appUrlScheme
components.host = "video-call"
components.queryItems = [
var queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
let serverCallId = extraServerCallId(call.data.extra)
if !serverCallId.isEmpty {
queryItems.append(URLQueryItem(name: "call_id", value: serverCallId))
}
components.queryItems = queryItems
guard let url = components.url else {
recordVoipDiagnostic(
@@ -475,16 +601,27 @@ import flutter_foreground_task
}
}
/// An ended VoIP push has no message id, so its CallKit id is the
/// conversation uuid, not the ringing call. End that conversation only.
/// The first endCall while the push flag is set closes the call this push
/// just reported; the next ones close the call that is actually ringing.
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
/// Ends 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.
private func endCallsForEndedPush(
reportedId: String,
conversationUuid: String,
serverCallId: String? = nil,
reason: String? = nil
) {
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
releaseIncomingCallClaim(reportedId)
return
}
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedServerCallId = serverCallId?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasServerCallId = !normalizedServerCallId.isEmpty
if hasServerCallId {
markCallTombstone(normalizedServerCallId)
}
var ids: [String] = []
func append(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -499,6 +636,16 @@ import flutter_foreground_task
let callId = (call["id"] as? String) ?? ""
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
let extraCallId = extraServerCallId(call["extra"])
if hasServerCallId {
let matchesServerCall =
callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
|| extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
if matchesServerCall {
append(callId)
}
continue
}
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
let sameConversation = !conversation.isEmpty && (
handle.caseInsensitiveCompare(conversation) == .orderedSame
@@ -515,11 +662,41 @@ import flutter_foreground_task
handle: conversation,
type: 1
)
// The plugin sends this payload with its "ended" event. Dart uses the
// marker to tell a remote end from a decline by the user.
var endExtra: [String: Any] = ["endedPush": true]
if !conversation.isEmpty {
endExtra["conversationUuid"] = conversation
}
if hasServerCallId {
endExtra["call_id"] = normalizedServerCallId
}
if let reason, !reason.isEmpty {
endExtra["reason"] = reason
}
endData.extra = endExtra as NSDictionary
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
}
private func activeCallId(matchingServerCallId serverCallId: String) -> String? {
let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
for call in calls {
let callId = (call["id"] as? String) ?? ""
guard !callId.isEmpty else { continue }
let extraCallId = extraServerCallId(call["extra"])
if callId.caseInsensitiveCompare(normalized) == .orderedSame
|| extraCallId.caseInsensitiveCompare(normalized) == .orderedSame
{
return callId
}
}
return nil
}
private func activeCallId(forConversation conversationUuid: String) -> String? {
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !conversation.isEmpty else { return nil }
@@ -538,21 +715,24 @@ import flutter_foreground_task
return nil
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map: [String: Any]
private func extraMap(_ extra: Any?) -> [String: Any] {
if let typed = extra as? [String: Any] {
map = typed
} else if let object = extra as? NSDictionary {
return typed
}
if let object = extra as? NSDictionary {
var converted: [String: Any] = [:]
for (key, value) in object {
if let key = key as? String {
converted[key] = value
}
}
map = converted
} else {
return ""
return converted
}
return [:]
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map = extraMap(extra)
for key in ["conversationUuid", "conversation_uuid"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -564,6 +744,19 @@ import flutter_foreground_task
return ""
}
private func extraServerCallId(_ extra: Any?) -> String {
let map = extraMap(extra)
for key in ["call_id", "callId"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return ""
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
@@ -604,11 +797,16 @@ import flutter_foreground_task
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
let serverCallId = voipServerCallId(from: dict)
let endReason = voipEndReason(from: dict)
if isEndedPush {
markCallTombstone(serverCallId)
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
"markers": pushMarkers,
"call_id": serverCallId ?? "",
"reason": endReason ?? "",
"payload": dict,
]
)
@@ -634,23 +832,28 @@ import flutter_foreground_task
?? ""
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedMessageId = voipMessageId(dict["message_id"])
let tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId)
let id: String
let callKitIdSourceName: String
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
if isEndedPush, let serverCallId,
let existing = activeCallId(matchingServerCallId: serverCallId)
{
id = existing
callKitIdSourceName = "active_call_id"
} else if isEndedPush, serverCallId == nil,
let existing = activeCallId(forConversation: trimmedConversationUuid)
{
// Legacy ended push without call_id.
id = existing
callKitIdSourceName = "active_conversation"
} else if let derived = derivedCallKitId(
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
) {
id = derived
callKitIdSourceName = "conversation_and_message"
} else if UUID(uuidString: trimmedConversationUuid) != nil {
id = trimmedConversationUuid.lowercased()
callKitIdSourceName = "conversation"
} else {
id = UUID().uuidString.lowercased()
callKitIdSourceName = "generated"
let resolved = resolveCallKitId(
serverCallId: serverCallId,
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
)
id = resolved.id
callKitIdSourceName = resolved.source
}
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
@@ -660,6 +863,12 @@ import flutter_foreground_task
if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = trimmedConversationUuid
}
if let serverCallId, !serverCallId.isEmpty {
extra["call_id"] = serverCallId
}
if let endReason, !endReason.isEmpty {
extra["reason"] = endReason
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
?? dict["avatar_url"] as? String
@@ -670,6 +879,8 @@ import flutter_foreground_task
}
if let messageId = dict["message_id"] as? String {
extra["messageId"] = messageId
} else if let messageId = parsedMessageId {
extra["messageId"] = String(messageId)
}
if let roomName = dict["room_name"] as? String {
extra["roomName"] = roomName
@@ -686,6 +897,22 @@ import flutter_foreground_task
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
extra["counterpartIsConsultant"] = isConsultant
}
// Closing this report looks like a user decline. Dart must not tell the
// backend, or the server sends another ended VoIP and the other phone
// shows it as a new incoming call.
if isEndedPush || tombstonedIncoming {
extra["endedPush"] = true
}
// An ended push with no ringing call still has to be reported. That
// report is closed at once, so it must not leave a trace anywhere.
// Tombstoned late incoming is treated the same way.
let matchedActiveRing =
callKitIdSourceName == "active_call_id"
|| callKitIdSourceName == "active_conversation"
let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming
let data = flutter_callkit_incoming.Data(
id: id,
@@ -693,27 +920,39 @@ import flutter_foreground_task
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.appName = "Итвио"
data.handleType = "generic"
data.supportsVideo = true
data.duration = 45000
data.extra = extra as NSDictionary
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
data.missedNotificationCallbackText = "Перезвонить"
data.isShowMissedCallNotification = !(isEndedPush || tombstonedIncoming)
data.includesCallsInRecents = !isPhantomEndedReport
// The plugin activates AVAudioSession itself on answer and again 1.2s
// later. That drops the ringing UI and leaves one side silent. CallKit
// and WebRTC own the session instead.
data.configureAudioSession = false
data.audioSessionActive = false
data.audioSessionMode = "videoChat"
data.ringtonePath = "system_ringtone_default"
// Do not set ringtonePath. The plugin stores that string as the ringtone
// file name. "system_ringtone_default" is not in the app, so CallKit ends
// the call at once and PushKit delivers the same push again.
// Remember the id so the in-app socket path does not report it again.
// The push itself must still be reported, or iOS kills the process.
_ = reserveIncomingCall(id)
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
"[EatvioVoIP] report incoming call id=\(id) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
)
recordVoipDiagnostic(
"voip_incoming_received",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"call_id": serverCallId ?? "",
"reason": endReason ?? "",
"tombstoned": tombstonedIncoming,
"conversationUuid": conversationUuid,
"nameCaller": nameCaller,
"isVideo": isVideo,
@@ -727,19 +966,30 @@ import flutter_foreground_task
callKitId: id,
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
serverCallId: serverCallId,
reason: endReason,
endAfterReport: isEndedPush || tombstonedIncoming,
endImmediately: isPhantomEndedReport,
completion: completion
)
}
/// PushKit kills the app unless every VoIP push is reported to CallKit and
/// its completion handler runs. Both happen here, including an "ended" push.
///
/// `endImmediately` is for an ended push that matched no ringing call. The
/// report is closed inside its own completion, before the incoming UI has a
/// chance to show. An end transaction would take one more round trip and
/// would also reach `onDecline`, which resets the in-app audio restart.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
serverCallId: String? = nil,
reason: String? = nil,
endAfterReport: Bool,
endImmediately: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
@@ -749,11 +999,19 @@ import flutter_foreground_task
}
let afterReport = {
if endAfterReport, endImmediately,
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
{
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
plugin.saveEndCall(callKitId, 6)
}
if endAfterReport {
DispatchQueue.main.async {
self.endCallsForEndedPush(
reportedId: callKitId,
conversationUuid: conversationUuid
conversationUuid: conversationUuid,
serverCallId: serverCallId,
reason: reason
)
}
}
@@ -765,6 +1023,8 @@ import flutter_foreground_task
data: [
"callKitId": callKitId,
"callKitIdSource": callKitIdSourceName,
"call_id": serverCallId ?? "",
"reason": reason ?? "",
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
@@ -775,6 +1035,9 @@ import flutter_foreground_task
finish()
}
if !endAfterReport {
prepareCallAudioCategory()
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
return
@@ -792,6 +1055,9 @@ import flutter_foreground_task
finish()
return
}
if !endAfterReport {
prepareCallAudioCategory()
}
voipCallReporter.report(
uuid: uuid,
callerName: data.nameCaller,
@@ -810,8 +1076,13 @@ import flutter_foreground_task
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
resumeAudioAfterCallKitEnds = true
prepareCallAudioCategory()
// CallKit stays up for the whole incoming call. Do not take the session
// back after a later deactivate — that path is hang-up.
resumeAudioAfterCallKitEnds = false
callKitOwnsAudioSession = true
// Category only. RTCAudioSession.setConfiguration here races CallKit's
// activation and can start I/O before didActivate.
prepareAvAudioSessionCategory()
storePendingAcceptedCall(call)
recordVoipDiagnostic(
"callkit_accept_native",
@@ -832,24 +1103,34 @@ import flutter_foreground_task
func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
action.fulfill()
}
func onEnd(_ call: Call, _ action: CXEndCallAction) {
// The in-app call is already leaving, or the user hung up from CallKit.
// Do not restart the audio unit after this deactivation.
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
action.fulfill()
}
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
callKitOwnsAudioSession = true
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.audioSessionDidActivate(audioSession)
applyCallAudioConfiguration(active: false)
// WebRTC must start I/O inside this CallKit callback. Enabling later
// misses the session CallKit just activated; toggling after that
// does not restart the ADM, so the incoming microphone stays silent.
rtcSession.isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
callKitOwnsAudioSession = false
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidDeactivate(audioSession)
// The audio unit is stopped, but isAudioEnabled stays true. A later
@@ -865,6 +1146,7 @@ import flutter_foreground_task
func providerDidReset() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
}
private func configureWebRTCAudioSession() {
@@ -872,33 +1154,62 @@ import flutter_foreground_task
RTCAudioSessionConfiguration.setWebRTC(configuration)
}
private func prepareAvAudioSessionCategory() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.allowBluetooth, .defaultToSpeaker]
)
} catch {
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
}
}
private func prepareCallAudioCategory() {
prepareAvAudioSessionCategory()
applyCallAudioConfiguration(active: false)
}
private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if callKitOwnsAudioSession {
rtcSession.isAudioEnabled = true
return
}
applyCallAudioConfiguration(active: true)
// Starting the audio unit while the configuration lock is held deadlocks
// the capture thread, so the mic stays silent.
rtcSession.isAudioEnabled = true
}
/// Restarts capture after the previous audio graph has disconnected.
/// Toggling the unit in the same turn makes voice processing throw.
/// Outgoing (no CallKit) may need a teardown to attach capture. Incoming
/// already started the unit in didActivate — toggling it here kills ADM.
private func restartInAppCallAudioUnit() {
audioRestartGeneration += 1
let generation = audioRestartGeneration
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if callKitOwnsAudioSession {
rtcSession.isAudioEnabled = true
return
}
audioRestartGeneration += 1
let generation = audioRestartGeneration
rtcSession.isAudioEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self, generation == self.audioRestartGeneration else { return }
self.activateInAppCallAudioSession()
self.enableInAppCallAudioUnit()
}
}
private func enableInAppCallAudioUnit() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if !callKitOwnsAudioSession {
applyCallAudioConfiguration(active: true)
}
rtcSession.isAudioEnabled = true
}
private func applyCallAudioConfiguration(active: Bool) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
@@ -915,6 +1226,10 @@ import flutter_foreground_task
audioRestartGeneration += 1
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false
// Hang-up stops the unit first, then Dart closes CallKit. A second
// setActive(false) while CallKit still owns the session fails or
// tears the graph down in the wrong order.
if callKitOwnsAudioSession { return }
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {

View File

@@ -69,13 +69,13 @@ class FirebaseApi {
}
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token != null && token.isNotEmpty) {
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
@@ -89,14 +89,17 @@ class FirebaseApi {
throw TimeoutException('registerPushToken timeout');
},
);
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// Incoming calls on iOS come only through the VoIP token. It does not
// depend on the FCM token, so a missing or failed FCM registration must
// not leave the device without calls.
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
class PhoneVerifyErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 405) {
final data = err.response?.data;
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
return handler.reject(
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
);
}
if (err.response?.statusCode != 405) {
return handler.next(err);
}
final requirement = VerificationRequirement.tryParse(err.response?.data);
if (requirement == null) {
return handler.next(err);
}
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
if (requirement.needsEmail) {
await _handleEmailVerificationRequired();
} else {
await _handlePhoneVerificationRequired();
}
return handler.reject(
DioException(
requestOptions: err.requestOptions,
response: err.response,
error: 'Phone number not verified',
type: DioExceptionType.badResponse,
),
);
}
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
Future<void> _handlePhoneVerificationRequired() async {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(
routeName: 'user_detailz.change-phone-verify',
routeParams: {"phone": phone},
);
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description:
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null &&
currentContext.mounted &&
GoRouter.of(currentContext).state.path ==
RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed(
'verifyScreen',
extra: passKey,
);
}
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
Future<void> _handleEmailVerificationRequired() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null || !context.mounted) {
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
return;
}
await showErrorDialog(
context,
title: 'Подтвердите e-mail',
description:
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
);
if (context.mounted) {
context.pushNamed('verifyEmailScreen', extra: true);
}
return handler.next(err);
}
}

View File

@@ -0,0 +1,61 @@
/// Канал верификации, которого не хватает для публичного действия.
enum VerificationChannel { phone, email }
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
///
/// Бэкенд отвечает на публичное действие без подтверждения так:
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
class VerificationRequirement {
const VerificationRequirement({required this.missing});
/// Каналы, подтверждение которых требует бэкенд.
final List<VerificationChannel> missing;
bool get needsEmail => missing.contains(VerificationChannel.email);
bool get needsPhone => missing.contains(VerificationChannel.phone);
/// Возвращает null, если ответ не является отказом верификации.
static VerificationRequirement? tryParse(Object? data) {
if (data is! Map) {
return null;
}
if (data['type'] != 'unverificated_phone') {
return null;
}
final Object? rawMissing = data['missing'];
if (rawMissing is! List) {
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
var hasPhone = false;
var hasEmail = false;
for (final Object? value in rawMissing) {
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
if (value == 'phone') {
hasPhone = true;
} else if (value == 'email') {
hasEmail = true;
}
}
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
if (!hasPhone && !hasEmail) {
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
return VerificationRequirement(
missing: [
if (hasPhone) VerificationChannel.phone,
if (hasEmail) VerificationChannel.email,
],
);
}
}

View File

@@ -62,12 +62,16 @@ class PushNavigationHandler {
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
developer.log(
'PushNavigationHandler: dismiss incoming call UI, source=$source',
'PushNavigationHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'PushNavigationHandler',
);
unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
),
);
return;

View File

@@ -34,7 +34,7 @@ class AuthRoutes {
name: 'verifyScreen',
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
),
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
GoRoute(
path: RouteConstants.registerViaSocialScreen,
name: 'registerViaSocialScreen',

View File

@@ -232,7 +232,9 @@ class ChatsCubit extends Cubit<ChatsState> {
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: message.roomId,
),
);
return;
}

View File

@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart';
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
ChatRoomStateLoaded state,
ChatUserModel chatCounterpart,
) {
sl<VideoCallManager>().permitParticipantTokenRequest();
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{

View File

@@ -18,8 +18,13 @@ enum PortionState {
@JsonValue('eaten_edited')
eatenEdited,
commented,
@JsonValue('commented_from_diet')
commentedFromDiet,
@JsonValue('reviewed_by_user')
reviewedByUser,
@JsonValue('reviewed_from_diet')
reviewedFromDiet,
unknown,
}
@freezed
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
@JsonKey(name: "quantity") required int quantity,
@JsonKey(name: "portion") required String portion,
@JsonKey(name: "from_diet") required bool fromDiet,
@JsonKey(name: "state") required PortionState portionState,
@JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
}) = _PortionModel;
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);

View File

@@ -162,17 +162,18 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
try {
// Logout revokes the Sanctum token. Unregister first, or
// FCM / VoIP tokens stay on the server and calls still ring.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();

View File

@@ -15,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class VerifyEmailScreen extends StatefulWidget {
const VerifyEmailScreen({super.key});
const VerifyEmailScreen({super.key, this.returnOnSuccess = false});
/// Вернуться на предыдущий экран вместо перехода в ленту после подтверждения.
/// Используется, когда экран открыт из публичного действия (пост, лайк, комментарий).
final bool returnOnSuccess;
@override
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
@@ -89,7 +93,12 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
_startTimer();
} else if (state is VerifyEmailStateVerifySuccess) {
showSuccessToast('Почта успешно подтверждена');
goRouter.pushNamed('feedScreen');
if (widget.returnOnSuccess) {
// Экран открыт из публичного действия — возвращаемся назад с результатом.
goRouter.pop(true);
} else {
goRouter.pushNamed('feedScreen');
}
} else if (state is VerifyEmailStateVerifyError) {
showErrorToast("Неверный код. Попробуйте снова.");
for (var controller in _controllers) {
@@ -166,18 +175,20 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
},
),
const SizedBox(height: 16),
CustomOutlinedButton(
text: 'Позже',
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
// При переходе из публичного действия уйти без подтверждения нельзя.
if (!widget.returnOnSuccess)
CustomOutlinedButton(
text: 'Позже',
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
),
onPressed: () {
context.read<VerifyEmailCubit>().skipVerification();
},
),
onPressed: () {
context.read<VerifyEmailCubit>().skipVerification();
},
),
],
),
),

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_foreground_c
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
@@ -18,6 +19,7 @@ import 'package:eatvio_app/features/video_calls/repositories/video_call_reposito
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:permission_handler/permission_handler.dart';
@@ -32,12 +34,14 @@ class VideoCallManager {
VideoCallForegroundCoordinator? foregroundCoordinator,
VideoCallLifecycleBinding? lifecycleBinding,
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
Future<String?> Function()? resolveHwId,
}) : _videoCallRepository = videoCallRepository,
_profileRepository = profileRepository,
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
_audioSession =
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
if (Platform.isIOS) {
// LiveKit rewrites the audio session whenever a remote audio track
@@ -52,6 +56,7 @@ class VideoCallManager {
final VideoCallForegroundCoordinator _foreground;
final VideoCallLifecycleBinding _lifecycleBinding;
final VideoCallAudioSessionCoordinator _audioSession;
final Future<String?> Function() _resolveHwId;
static const lk.AudioCaptureOptions _micCaptureOptions =
lk.AudioCaptureOptions(
@@ -77,6 +82,10 @@ class VideoCallManager {
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
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;
bool _joinInProgress = false;
@@ -100,6 +109,14 @@ class VideoCallManager {
bool get callEndedNotified => _callEndedNotified;
String? get activeCallId => _activeCallId;
void rememberCallId(String? callId) {
final id = callId?.trim();
if (id == null || id.isEmpty) return;
_activeCallId = id;
}
bool get isActiveCall =>
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
_callStageEntered &&
@@ -178,13 +195,29 @@ class VideoCallManager {
);
}
Future<void> startCall({
/// Lets the next [startCall] ask the server for a token.
///
/// Only the call button and an accepted incoming call set this. A screen
/// that reappears after hang-up must not request a token: that request is
/// what rings the other person again.
void permitParticipantTokenRequest() {
_sessionMayRequestToken = true;
}
Future<bool> startCall({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
bool micEnabled = false,
bool videoEnabled = false,
}) async {
final allowed = mayRequestParticipantToken(
sessionPermitted: _sessionMayRequestToken,
leftAt: _leftConversationAt[conversationUuid],
now: DateTime.now(),
);
if (!allowed) return false;
if (_room != null &&
!_liveKitDisposed &&
_snapshot.conversationUuid == conversationUuid) {
@@ -199,7 +232,7 @@ class VideoCallManager {
);
_ensureLifecycleAttached();
unawaited(_startCallAudioSession());
return;
return true;
}
if (!_callStageEntered &&
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
@@ -213,11 +246,12 @@ class VideoCallManager {
),
);
_ensureLifecycleAttached();
return;
return true;
}
}
await endCall();
_sessionMayRequestToken = true;
_resetSessionFlagsForNewRoom();
_callEndedNotified = false;
@@ -255,9 +289,10 @@ class VideoCallManager {
);
await _prepareLiveKitConnection();
return true;
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -267,7 +302,7 @@ class VideoCallManager {
if (_snapshot.conversationUuid != conversationUuid ||
_room == null ||
_liveKitDisposed) {
await startCall(
return startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
@@ -286,6 +321,7 @@ class VideoCallManager {
unawaited(_ensureLocalParticipantNamePublished());
_syncRemoteParticipantNameFromRoom();
}
return true;
}
}
@@ -413,32 +449,40 @@ class VideoCallManager {
);
if (uuid.isNotEmpty) {
_callEndedNotified = true;
_leftConversationAt[uuid] = DateTime.now();
}
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
if (shouldNotifyBackend) {
try {
await _videoCallRepository.notifyCallEnded(uuid);
await _videoCallRepository.notifyCallEnded(uuid, callId: _activeCallId);
} catch (_) {}
}
await _foreground.stopOngoingCall();
await _stopCallAudioSession();
await _dismissIosCallKitAfterHangup(uuid);
_lifecycleBinding.detach();
_callStageEntered = false;
_videoPausedForBackground = false;
_liveKitDisposed = true;
_myProfileNameFuture = null;
_activeCallId = null;
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// Notifies backend that this device accepted an incoming call so ringing
/// stops on the user's other devices.
Future<void> notifyIncomingCallAccepted(String conversationUuid) async {
Future<void> notifyIncomingCallAccepted(
String conversationUuid, {
String? callId,
}) async {
final uuid = conversationUuid.trim();
if (uuid.isEmpty) return;
rememberCallId(callId);
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
final hwId = (await DeviceHwIdProvider.current())?.trim();
final hwId = (await _resolveHwId())?.trim();
if (hwId == null || hwId.length < 8) return;
_callAcceptNotifiedConversationUuids.add(uuid);
@@ -446,6 +490,7 @@ class VideoCallManager {
await _videoCallRepository.notifyCallAccepted(
conversationUuid: uuid,
hwId: hwId,
callId: _activeCallId ?? callId,
);
} catch (_) {
_callAcceptNotifiedConversationUuids.remove(uuid);
@@ -453,22 +498,68 @@ class VideoCallManager {
}
/// Decline from incoming UI before joining the LiveKit room.
Future<void> declineIncomingCall(String conversationUuid) async {
///
/// Uses `POST /decline` so the backend can stop ringing on the user's
/// other devices. Do not call `/ended` for a ringing decline.
Future<void> declineIncomingCall(
String conversationUuid, {
String? callId,
}) async {
final uuid = conversationUuid.trim();
final resolvedCallId = callId?.trim().isNotEmpty == true
? callId!.trim()
: _activeCallId;
if (uuid.isEmpty) {
await endCall();
await _cleanupLocalCallAfterDecline();
return;
}
if (_snapshot.conversationUuid == uuid) {
await endCall();
final declineKey = '${uuid.toLowerCase()}|${resolvedCallId ?? ''}';
final shouldNotify = incomingDeclineShouldNotifyBackend(
leftAt: _leftConversationAt[uuid],
now: DateTime.now(),
);
await _cleanupLocalCallAfterDecline(conversationUuid: uuid);
if (!shouldNotify || _callDeclineNotifiedKeys.contains(declineKey)) {
return;
}
await endCall();
final hwId = (await _resolveHwId())?.trim();
if (hwId == null || hwId.length < 8) return;
_callDeclineNotifiedKeys.add(declineKey);
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
await _videoCallRepository.notifyCallDeclined(
conversationUuid: uuid,
hwId: hwId,
callId: resolvedCallId,
);
} catch (_) {
_callDeclineNotifiedKeys.remove(declineKey);
}
}
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
final uuid = conversationUuid?.trim();
if (uuid != null && uuid.isNotEmpty) {
_leftConversationAt[uuid] = DateTime.now();
}
_callEndedNotified = true;
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
await _foreground.stopOngoingCall();
await _stopCallAudioSession();
await _dismissIosCallKitAfterHangup(uuid);
_lifecycleBinding.detach();
_callStageEntered = false;
_videoPausedForBackground = false;
_liveKitDisposed = true;
_myProfileNameFuture = null;
_activeCallId = null;
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// [currentUiEnabled] reflects what the user sees on the mic control
@@ -776,6 +867,7 @@ class VideoCallManager {
_roomConnectFuture = null;
_clearPreparedConnectionCredentials();
_callAcceptNotifiedConversationUuids.clear();
_callDeclineNotifiedKeys.clear();
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
}
@@ -977,13 +1069,16 @@ class VideoCallManager {
Future<void> _prepareLiveKitConnection() async {
final room = _room;
if (room == null) return;
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
_emitSnapshot(_snapshot.copyWith(connectError: null));
try {
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
callId: _activeCallId,
);
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
_preparedWsUrl = tokenResponse.wsUrl;
_preparedToken = tokenResponse.token;
@@ -1025,10 +1120,13 @@ class VideoCallManager {
var wsUrl = _preparedWsUrl;
var token = _preparedToken;
if (wsUrl == null || token == null) {
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
callId: _activeCallId,
);
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
wsUrl = tokenResponse.wsUrl;
token = tokenResponse.token;
_preparedWsUrl = wsUrl;
@@ -1089,6 +1187,11 @@ class VideoCallManager {
if (status.isGranted) return true;
status = await Permission.microphone.request();
if (status.isGranted) return true;
if (Platform.isIOS) {
// CallKit already opened a recording session. A stale permission
// status must not block publishing the incoming microphone.
return true;
}
final ctx2 = _navigatorContext;
if (ctx2 == null || !ctx2.mounted) return false;
final s = S.of(ctx2);
@@ -1103,6 +1206,14 @@ class VideoCallManager {
return false;
}
bool _tokenRequestAllowed(String conversationUuid) {
return mayRequestParticipantToken(
sessionPermitted: _sessionMayRequestToken,
leftAt: _leftConversationAt[conversationUuid],
now: DateTime.now(),
);
}
bool _roomHasRemoteUser() {
final room = _room;
if (room == null) return false;
@@ -1205,4 +1316,27 @@ class VideoCallManager {
await _audioSession.stop();
} catch (_) {}
}
/// Incoming iOS keeps CallKit up for the whole call so the microphone
/// session stays. Hang-up has to close that CallKit call.
Future<void> _dismissIosCallKitAfterHangup(String? conversationUuid) async {
if (!Platform.isIOS) return;
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (activeCalls.isEmpty) return;
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
for (final call in activeCalls) {
final handle = call.handle?.trim().toLowerCase();
final extraUuid =
call.extra?['conversationUuid']?.toString().trim().toLowerCase() ??
call.extra?['conversation_uuid']?.toString().trim().toLowerCase();
final matches = uuid.isEmpty || handle == uuid || extraUuid == uuid;
if (!matches) continue;
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
}
}

View File

@@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
required String token,
required String roomName,
required String wsUrl,
@JsonKey(name: 'call_id') String? callId,
@JsonKey(name: 'call_status') String? callStatus,
@JsonKey(name: 'call_expires_at') String? callExpiresAt,
}) = _VideoCallTokenResponse;
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
@@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
@freezed
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
const factory VideoCallEndedResponse({required bool sent}) =
_VideoCallEndedResponse;
const factory VideoCallEndedResponse({
required bool sent,
@JsonKey(name: 'call_id') String? callId,
String? status,
}) = _VideoCallEndedResponse;
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
@@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
const factory VideoCallAcceptResponse({
required bool sent,
@JsonKey(name: 'devices_notified') required int devicesNotified,
@JsonKey(name: 'call_id') String? callId,
String? status,
@JsonKey(name: 'already_accepted') bool? alreadyAccepted,
}) = _VideoCallAcceptResponse;
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallAcceptResponseFromJson(json);
}
@freezed
abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse {
const factory VideoCallDeclineResponse({
required bool sent,
@JsonKey(name: 'devices_notified') required int devicesNotified,
@JsonKey(name: 'call_id') String? callId,
String? status,
}) = _VideoCallDeclineResponse;
factory VideoCallDeclineResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallDeclineResponseFromJson(json);
}

View File

@@ -171,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
);
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -225,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return;
return true;
}
if (isResumingActiveCall) {
@@ -246,7 +246,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
counterpartName: counterpartName,
localParticipantName: localParticipantName,
counterpartIsConsultant: counterpartIsConsultant ?? false,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
callStageEntered: false,
connectError: null,
@@ -255,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
entryMode: effectiveEntryMode,
),
);
await _videoCallManager.startCall(
final started = await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
);
if (!started) {
if (!isClosed) emit(const VideoCallSessionState());
return false;
}
emit(state.copyWith(entryMode: effectiveEntryMode));
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
@@ -273,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return true;
}
Future<void> _ensureSystemPipArmedIfInCall() async {
@@ -366,10 +371,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _requestMicBeforeIncomingEnter() async {
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
final granted = await _requestMicPermissionForCall();
if (!granted || isClosed) return;
await _requestMicPermissionForCall();
if (isClosed) return;
if (state.isMicActiveForUi) return;
await _videoCallManager.toggleMic(state.isMicActiveForUi);
await _videoCallManager.toggleMic(false);
}
@visibleForTesting

View File

@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_initCounterpartMetadata());
unawaited(
_cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid,
counterpartName: widget.counterpartName,
localParticipantName: widget.localParticipantName,
counterpartIsConsultant: widget.counterpartIsConsultant,
entryMode: widget.entryMode,
),
);
unawaited(_openCallSession());
});
}
Future<void> _openCallSession() async {
final started = await _cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid,
counterpartName: widget.counterpartName,
localParticipantName: widget.localParticipantName,
counterpartIsConsultant: widget.counterpartIsConsultant,
entryMode: widget.entryMode,
);
if (!mounted || started) return;
if (context.canPop()) context.pop();
}
Future<void> _initCounterpartMetadata() async {
var isOnline = widget.counterpartIsOnline ?? false;
var lastSeenText = widget.counterpartLastSeenText ?? '';

View File

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

View File

@@ -8,7 +8,10 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
@@ -75,6 +78,8 @@ class VideoCallCallKitCoordinator {
_scheduleNativeVoipDiagnosticsDrain();
case 'acceptedVoipCall':
await _handleNativeAcceptedVoipCall(call.arguments);
case 'tombstoneCallId':
_handleNativeTombstoneCallId(call.arguments);
}
});
await _firebaseApi.registerPushToken();
@@ -362,7 +367,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleDecline(id);
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _handleEndedEvent(CallEvent event) async {
@@ -374,7 +379,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleEnded(id);
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
@@ -515,6 +520,17 @@ class VideoCallCallKitCoordinator {
);
}
void _handleNativeTombstoneCallId(Object? arguments) {
final data = _stringKeyedMap(arguments);
final callId = _parseString(data, const ['id', 'call_id', 'callId']);
if (callId == null || callId.isEmpty) return;
VideoCallTombstoneRegistry.mark(callId);
log(
'VideoCallCallKitCoordinator: native tombstone call_id=$callId',
name: 'VideoCallCallKit',
);
}
Future<void> _handleNativeAcceptedVoipCall(Object? arguments) async {
if (!Platform.isIOS) return;
@@ -541,6 +557,9 @@ class VideoCallCallKitCoordinator {
return;
}
final callId = _parseCallId(extra) ?? callKitId;
_manager.rememberCallId(callId);
await _deliverIncomingAccept(
callKitId: callKitId,
conversationUuid: conversationUuid,
@@ -698,8 +717,16 @@ class VideoCallCallKitCoordinator {
}) async {
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
_suppressedEndCallKitIds.add(callKitId);
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
final params = _paramsForCallId(callKitId);
final callId =
_parseCallId(params?.extra ?? const <String, Object?>{}) ??
(callKitId.contains('-') ? callKitId : null);
if (callId != null) {
_manager.rememberCallId(callId);
}
unawaited(
_manager.notifyIncomingCallAccepted(conversationUuid, callId: callId),
);
try {
await _waitForAcceptNavigationReady();
@@ -738,7 +765,7 @@ class VideoCallCallKitCoordinator {
unawaited(_drainPendingIncomingAcceptWithRetries());
}
await FlutterCallkitIncoming.endCall(callKitId);
await _finishCallKitAfterAccept(callKitId);
} catch (error, stackTrace) {
log(
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
@@ -746,12 +773,17 @@ class VideoCallCallKitCoordinator {
stackTrace: stackTrace,
);
} finally {
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_suppressedEndCallKitIds.remove(callKitId);
_acceptNavigationCompletedIds.remove(callKitId);
}),
);
// Android ends CallKit right after accept, so the plugin ENDED event
// must be ignored briefly. iOS keeps CallKit for the whole call: a
// later ENDED is a real hang-up and must not be swallowed.
if (Platform.isAndroid) {
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_suppressedEndCallKitIds.remove(callKitId);
_acceptNavigationCompletedIds.remove(callKitId);
}),
);
}
}
}
@@ -806,10 +838,24 @@ class VideoCallCallKitCoordinator {
} catch (_) {}
}
try {
await FlutterCallkitIncoming.endCall(pending.callKitId);
await _finishCallKitAfterAccept(pending.callKitId);
} catch (_) {}
}
/// After answer: Android dismisses the incoming notification. iOS keeps the
/// CallKit call so the audio session stays, and marks it connected so the
/// plugin stops treating every later `endCall` as the last PushKit report.
Future<void> _finishCallKitAfterAccept(String callKitId) async {
if (Platform.isIOS) {
try {
await FlutterCallkitIncoming.setCallConnected(callKitId);
} catch (_) {}
return;
}
_suppressedEndCallKitIds.add(callKitId);
await FlutterCallkitIncoming.endCall(callKitId);
}
Future<void> _settleUiAfterResume() async {
final binding = WidgetsBinding.instance;
for (var frame = 0; frame < 3; frame++) {
@@ -842,6 +888,7 @@ class VideoCallCallKitCoordinator {
bool? counterpartIsConsultant,
}) async {
FlutterNativeSplash.remove();
_manager.permitParticipantTokenRequest();
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
@@ -853,7 +900,10 @@ class VideoCallCallKitCoordinator {
);
}
Future<void> _handleDecline(String callKitId) async {
Future<void> _handleDecline(
String callKitId, {
Map<String, dynamic>? eventExtra,
}) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
@@ -863,11 +913,31 @@ class VideoCallCallKitCoordinator {
}
final params = _paramsForCallId(callKitId);
final extra = params?.extra ?? <String, dynamic>{};
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
final conversationUuid = _parseConversationUuid(
extra,
handle: params?.handle ?? callKitId,
);
final callId =
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null);
final remoteEndDismissal =
callKitDeclineIsEndedPush(
cachedExtra: params?.extra,
eventExtra: eventExtra,
) ||
VideoCallRemoteEndRegistry.contains(callKitId);
if (remoteEndDismissal) {
// The call is already closed on the native side. Another endCall is
// not needed: while the plugin's push flag is set it would end the
// last reported call instead of this id, which may be a newer ring.
log(
'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId',
name: 'VideoCallCallKit',
);
_forgetCallKitId(callKitId);
return;
}
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -879,24 +949,25 @@ class VideoCallCallKitCoordinator {
}),
);
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
_forgetCallKitId(callKitId);
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
await _cubit.endCall();
VideoCallNotificationNavigator.closeCallRouteIfOpen();
return;
}
await _manager.declineIncomingCall(conversationUuid);
await _manager.declineIncomingCall(conversationUuid, callId: callId);
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
await _cubit.onCallEndedExternally();
}
}
Future<void> _handleEnded(String callKitId) async {
Future<void> _handleEnded(
String callKitId, {
Map<String, dynamic>? eventExtra,
}) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
@@ -905,16 +976,53 @@ class VideoCallCallKitCoordinator {
return;
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
// iOS reports an ended VoIP push to CallKit and closes it right away.
// That close is marked and must be remembered before the decline (or the
// later timeout) event for the same id arrives with no marker.
final remoteEnd =
callKitDeclineIsEndedPush(
cachedExtra: _paramsForCallId(callKitId)?.extra,
eventExtra: eventExtra,
) ||
VideoCallRemoteEndRegistry.contains(callKitId);
if (remoteEnd) {
VideoCallRemoteEndRegistry.mark(callKitId);
}
_forgetCallKitId(callKitId);
final inAppCall =
_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle;
if (inAppCall) {
if (remoteEnd) {
await _cubit.handleRemoteCallEnded();
} else {
await _cubit.endCall();
VideoCallNotificationNavigator.closeCallRouteIfOpen();
}
return;
}
// Native already ends a remote-ended ringing call. Calling endCall
// again here overwrites the plugin's stored payload, so the decline
// that follows would lose the marker.
if (remoteEnd) return;
try {
await FlutterCallkitIncoming.endCall(callKitId);
} catch (_) {}
}
void _forgetCallKitId(String callKitId) {
_callKitParamsById.remove(callKitId);
_acceptNavigationCompletedIds.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
}
static String _parseConversationUuid(
Map<String, Object?> extra, {
String? handle,
@@ -933,6 +1041,10 @@ class VideoCallCallKitCoordinator {
return '';
}
static String? _parseCallId(Map<String, Object?> extra) {
return _parseString(extra, const ['call_id', 'callId']);
}
static String? _parseString(Map<String, Object?> map, List<String> keys) {
for (final key in keys) {
final value = map[key];

View File

@@ -0,0 +1,35 @@
/// Cause of a `video_call_ended` push / Echo event.
///
/// This is not the session status. It tells the client how to close ringing
/// or an active call.
enum VideoCallEndReason {
accepted,
declined,
cancelled,
ended,
timeout;
static VideoCallEndReason? tryParse(Object? raw) {
if (raw == null) return null;
final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_');
if (normalized.isEmpty) return null;
for (final value in VideoCallEndReason.values) {
if (value.name == normalized) return value;
}
if (normalized == 'hangup' || normalized == 'hang_up') {
return VideoCallEndReason.ended;
}
return null;
}
/// Close ringing UI; do not tear down an already-accepted LiveKit call.
bool get dismissesRingingOnly =>
this == VideoCallEndReason.accepted ||
this == VideoCallEndReason.declined ||
this == VideoCallEndReason.cancelled ||
this == VideoCallEndReason.timeout;
/// Tear down an accepted in-progress call.
bool get endsActiveCall =>
this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout;
}

View File

@@ -1,7 +1,11 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -12,30 +16,112 @@ abstract final class VideoCallEndedPushHandler {
'com.eatvioapp/video_call_push',
);
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
await endActiveCallIfMatches(conversationUuid);
static Future<void> dismissIncomingCallUi({
String? conversationUuid,
String? callId,
VideoCallEndReason? reason,
}) async {
final normalizedCallId = callId?.trim();
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
VideoCallTombstoneRegistry.mark(normalizedCallId);
}
log(
'VideoCallEndedPushHandler: dismiss '
'call_id=${normalizedCallId ?? '-'} '
'conversation=${conversationUuid ?? '-'} '
'reason=${reason?.name ?? '-'}',
name: 'VideoCallEndedPush',
);
final shouldEndActive = reason == null || reason.endsActiveCall;
if (shouldEndActive && reason != VideoCallEndReason.accepted) {
await endActiveCallIfMatches(
conversationUuid: conversationUuid,
callId: normalizedCallId,
);
}
if (Platform.isAndroid) {
try {
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
await _androidPushChannel.invokeMethod<void>(
'dismissIncomingCalls',
<String, String?>{
if (normalizedCallId != null && normalizedCallId.isNotEmpty)
'call_id': normalizedCallId,
if (conversationUuid != null && conversationUuid.trim().isNotEmpty)
'conversation_uuid': conversationUuid.trim(),
},
);
return;
} catch (_) {}
}
await _dismissViaCallKit(conversationUuid);
await _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: normalizedCallId,
);
}
static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
/// Closes the CallKit / incoming UI without ending the LiveKit session.
/// Used when this client hangs up: CallKit stays up for the whole incoming
/// call on iOS and must be dismissed with the in-app hang-up.
static Future<void> dismissCallKitOnly({
String? conversationUuid,
String? callId,
}) {
return _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: callId,
);
}
static Future<void> endActiveCallIfMatches({
String? conversationUuid,
String? callId,
}) async {
final uuid = conversationUuid?.trim();
if (uuid == null || uuid.isEmpty) return;
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
final cubit = sl<VideoCallSessionCubit>();
if (cubit.state.conversationUuid != uuid) return;
// callId is reserved for ringing dismiss matching; active sessions are
// still keyed by conversation until the cubit stores call_id.
try {
await cubit.handleRemoteCallEnded();
} catch (_) {}
}
static bool callMatches({
required CallKitParams call,
String? conversationUuid,
String? callId,
}) {
final normalizedCallId = callId?.trim().toLowerCase();
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
final id = call.id.trim().toLowerCase();
if (id.isNotEmpty && id == normalizedCallId) return true;
final extra = call.extra;
if (extra != null) {
for (final key in const ['call_id', 'callId']) {
final value = extra[key];
if (value is String &&
value.trim().toLowerCase() == normalizedCallId) {
return true;
}
}
}
// When call_id is known, never fall back to conversation matching:
// a stale ended event must not close a newer ring in the same chat.
return false;
}
final uuid = conversationUuid?.trim().toLowerCase();
if (uuid == null || uuid.isEmpty) return false;
return callMatchesConversation(call, uuid);
}
static bool callMatchesConversation(
CallKitParams call,
String conversationUuid,
@@ -53,16 +139,36 @@ abstract final class VideoCallEndedPushHandler {
return false;
}
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
/// The close of a ringing call comes back from the plugin as a decline.
/// Each id is marked first, so the coordinator does not report that
/// decline to the backend as if the user had rejected the call.
static Future<void> _dismissViaCallKit({
String? conversationUuid,
String? callId,
}) async {
final uuid = conversationUuid?.trim();
final normalizedCallId = callId?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
final hasTarget =
(normalizedCallId != null && normalizedCallId.isNotEmpty) ||
(uuid != null && uuid.isNotEmpty);
if (!hasTarget) {
for (final call in activeCalls) {
VideoCallRemoteEndRegistry.mark(call.id);
}
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
if (!callMatches(
call: call,
conversationUuid: uuid,
callId: normalizedCallId,
)) {
continue;
}
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}

View File

@@ -1,23 +1,58 @@
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
/// Marker for a data-only `video_call_ended` push.
class VideoCallEndedPushPayload {
const VideoCallEndedPushPayload({this.conversationUuid});
const VideoCallEndedPushPayload({
this.conversationUuid,
this.callId,
this.reason,
this.acceptedDeviceId,
this.acceptedClient,
});
final String? conversationUuid;
final String? callId;
final VideoCallEndReason? reason;
final String? acceptedDeviceId;
final String? acceptedClient;
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
final rawUuid =
data['conversationUuid'] ??
data['conversation_uuid'] ??
data['uuid'];
final conversationUuid = rawUuid?.toString().trim();
final conversationUuid = _string(data, const [
'conversationUuid',
'conversation_uuid',
'uuid',
]);
final callId = _string(data, const ['call_id', 'callId']);
final acceptedDeviceId = _string(data, const [
'accepted_device_id',
'acceptedDeviceId',
]);
final acceptedClient = _string(data, const [
'accepted_client',
'acceptedClient',
]);
return VideoCallEndedPushPayload(
conversationUuid:
conversationUuid == null || conversationUuid.isEmpty
? null
: conversationUuid,
conversationUuid: conversationUuid,
callId: callId,
reason: VideoCallEndReason.tryParse(
data['reason'] ?? data['end_reason'] ?? data['endReason'],
),
acceptedDeviceId: acceptedDeviceId,
acceptedClient: acceptedClient,
);
}
static String? _string(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
}

View File

@@ -58,12 +58,16 @@ abstract final class VideoCallFcmHandler {
}
log(
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
'VideoCallFcmHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'VideoCallFcm',
);
try {
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
);
} catch (e, st) {
log(

View File

@@ -1,7 +1,9 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -9,6 +11,8 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
typedef VideoCallAndroidIncomingShow =
Future<void> Function(VideoCallPushPayload payload);
/// Shows the system incoming-call UI.
///
@@ -18,9 +22,16 @@ typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
/// never arrived.
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
/// Same texts as the native VoIP path in `AppDelegate.swift`.
static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок';
static const String iosMissedCallCallbackText = 'Перезвонить';
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
static const MethodChannel _androidPushChannel = MethodChannel(
'com.eatvioapp/video_call_push',
);
/// Absolute delays from the start of an iOS present attempt.
static const List<Duration> iosClaimPollDelays = <Duration>[
@@ -38,18 +49,29 @@ abstract final class VideoCallIncomingPushPresenter {
VideoCallIncomingShow? showCallkitIncoming,
VideoCallIncomingDelay? delay,
List<Duration>? claimPollDelays,
VideoCallAndroidIncomingShow? showAndroidIncoming,
}) async {
final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final ios = isIos ?? Platform.isIOS;
final callId = ios
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
) ??
payload.conversationUuid
: payload.conversationUuid;
if (VideoCallTombstoneRegistry.contains(payload.callId)) {
log(
'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}',
name: 'VideoCallIncoming',
);
return;
}
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
return;
}
final callId = VideoCallKitId.resolve(
callId: payload.callId,
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
isIos: ios,
);
if (ios) {
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
@@ -77,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter {
duration: 45000,
extra: <String, dynamic>{
'conversationUuid': payload.conversationUuid,
if (payload.callId != null) 'call_id': payload.callId,
if (payload.counterpartName != null)
'counterpartName': payload.counterpartName,
if (payload.counterpartAvatarUrl != null)
@@ -85,8 +108,14 @@ abstract final class VideoCallIncomingPushPresenter {
'localParticipantName': payload.localParticipantName,
if (payload.counterpartIsConsultant != null)
'counterpartIsConsultant': payload.counterpartIsConsultant,
if (payload.messageId != null) 'message_id': payload.messageId,
},
callingNotification: const NotificationParams(showNotification: false),
missedCallNotification: const NotificationParams(
showNotification: true,
subtitle: iosMissedCallSubtitle,
callbackText: iosMissedCallCallbackText,
),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
@@ -106,7 +135,6 @@ abstract final class VideoCallIncomingPushPresenter {
configureAudioSession: false,
audioSessionActive: false,
audioSessionMode: 'videoChat',
ringtonePath: 'system_ringtone_default',
),
);
@@ -115,6 +143,30 @@ abstract final class VideoCallIncomingPushPresenter {
);
}
static Future<void> _showAndroidIncoming(VideoCallPushPayload payload) async {
try {
await _androidPushChannel
.invokeMethod<void>('showIncomingCall', <String, String>{
'conversation_uuid': payload.conversationUuid,
if (payload.callId != null) 'call_id': payload.callId!,
if (payload.counterpartName != null)
'caller_name': payload.counterpartName!,
if (payload.counterpartAvatarUrl != null)
'caller_avatar': payload.counterpartAvatarUrl!,
if (payload.localParticipantName != null)
'local_participant_name': payload.localParticipantName!,
if (payload.counterpartIsConsultant != null)
'is_consultant': payload.counterpartIsConsultant!
? 'true'
: 'false',
if (payload.messageId != null)
'message_id': payload.messageId.toString(),
if (payload.expiresAt != null)
'expires_at': payload.expiresAt!.toUtc().toIso8601String(),
});
} catch (_) {}
}
static Future<bool> _claimOrFalse(
VideoCallIncomingClaim claim,
String callId,

View File

@@ -1,14 +1,33 @@
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
/// Resolves the CallKit / ConnectionService id for an incoming call.
///
/// The conversation uuid stays in the call payload for navigation. The CallKit
/// id is derived from that uuid plus the chat message id, so one ring is one
/// call and the next ring in the same chat still appears. Android notifications
/// keep the conversation uuid: the same id updates one incoming notification.
/// Prefer the backend `call_id` (stable for the whole session). Legacy
/// fallback: conversation uuid + message id on iOS, conversation uuid on
/// Android.
abstract final class VideoCallKitId {
static final RegExp _uuidPrefix = RegExp(
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
);
static String resolve({
String? callId,
required String conversationUuid,
int? messageId,
required bool isIos,
}) {
final serverCallId = callId?.trim();
if (serverCallId != null && serverCallId.isNotEmpty) {
return serverCallId;
}
if (isIos) {
return iosUuid(
conversationUuid: conversationUuid,
messageId: messageId,
) ??
conversationUuid;
}
return conversationUuid;
}
static String? iosUuid({
required String conversationUuid,
required int? messageId,

View File

@@ -30,6 +30,58 @@ bool shouldNotifyCallEnded({
return true;
}
/// An ended VoIP push has to be reported to CallKit, then closed. That close
/// arrives as a decline. Reporting it to the backend sends another ended VoIP,
/// which the other phone shows as a new incoming call.
const Duration incomingDeclineNotifySuppression = Duration(seconds: 60);
bool incomingDeclineShouldNotifyBackend({
required DateTime? leftAt,
required DateTime now,
Duration suppressFor = incomingDeclineNotifySuppression,
}) {
if (leftAt == null) return true;
return now.difference(leftAt) >= suppressFor;
}
/// A token request after hang-up makes the server send a new incoming call.
/// Automatic retries stay blocked until the user starts or accepts a call.
bool mayRequestParticipantToken({
required bool sessionPermitted,
required DateTime? leftAt,
required DateTime now,
Duration suppressFor = incomingDeclineNotifySuppression,
}) {
if (sessionPermitted) return true;
return incomingDeclineShouldNotifyBackend(
leftAt: leftAt,
now: now,
suppressFor: suppressFor,
);
}
bool callKitExtraMarksEndedPush(Map<String, dynamic>? extra) {
final value = extra?['endedPush'];
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
return normalized == 'true' || normalized == '1';
}
return false;
}
/// Either the cached incoming payload or the decline event itself can carry
/// the marker. The decline often arrives with the push payload, while the
/// cache still holds the original ringing call.
bool callKitDeclineIsEndedPush({
Map<String, dynamic>? cachedExtra,
Map<String, dynamic>? eventExtra,
}) {
return callKitExtraMarksEndedPush(cachedExtra) ||
callKitExtraMarksEndedPush(eventExtra);
}
bool shouldForceEndCallAfterRoomDisconnect({
required bool callStageEntered,
required bool sessionConnected,

View File

@@ -6,6 +6,8 @@ class VideoCallPushPayload {
this.localParticipantName,
this.counterpartIsConsultant,
this.messageId,
this.callId,
this.expiresAt,
});
final String conversationUuid;
@@ -14,6 +16,8 @@ class VideoCallPushPayload {
final String? localParticipantName;
final bool? counterpartIsConsultant;
final int? messageId;
final String? callId;
final DateTime? expiresAt;
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
if (!isIncomingVideoCall(data)) return null;
@@ -52,6 +56,8 @@ class VideoCallPushPayload {
'isConsultant',
]),
messageId: _messageId(data),
callId: _string(data, const ['call_id', 'callId']),
expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']),
);
}
@@ -119,6 +125,9 @@ class VideoCallPushPayload {
static String? conversationUuidFromData(Map<String, dynamic> data) =>
_conversationUuid(data);
static String? callIdFromData(Map<String, dynamic> data) =>
_string(data, const ['call_id', 'callId']);
static String? _conversationUuid(Map<String, dynamic> data) {
final fromData = _string(data, const [
'conversationUuid',
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
return null;
}
static DateTime? _dateTime(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is DateTime) return value;
if (value is String && value.trim().isNotEmpty) {
return DateTime.tryParse(value.trim());
}
}
return null;
}
static bool _isTruthy(Object? value) {
if (value == null) return false;
if (value is bool) return value;

View File

@@ -0,0 +1,37 @@
/// CallKit ids that were closed because the call ended remotely.
///
/// On iOS every close of a ringing call comes back from the plugin as a
/// decline, whether the user tapped Decline or the app ended the call after a
/// `video_call_ended` signal. Only a decline by the user may be reported to
/// the backend; the other kind would make the server send one more ended
/// push and the other phone would show it as a new incoming call.
abstract final class VideoCallRemoteEndRegistry {
/// Longer than the 45 s ring timeout: the plugin still emits a timeout
/// event for a call it reported and closed right away.
static const Duration ttl = Duration(minutes: 2);
static final Map<String, DateTime> _markedAt = <String, DateTime>{};
static void mark(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return;
final at = now ?? DateTime.now();
_prune(at);
_markedAt[id] = at;
}
static bool contains(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return false;
_prune(now ?? DateTime.now());
return _markedAt.containsKey(id);
}
static void clear() => _markedAt.clear();
static String _normalize(String callKitId) => callKitId.trim().toLowerCase();
static void _prune(DateTime now) {
_markedAt.removeWhere((_, markedAt) => now.difference(markedAt) >= ttl);
}
}

View File

@@ -11,6 +11,7 @@ abstract class VideoCallService {
@GET('/api/v2/chat/{conversation}/video-call/token')
Future<VideoCallTokenResponse> getParticipantToken({
@Path('conversation') required String conversationUuid,
@Query('call_id') String? callId,
});
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
@@ -29,4 +30,10 @@ abstract class VideoCallService {
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
@POST('/api/v2/chat/{conversation}/video-call/decline')
Future<VideoCallDeclineResponse> notifyCallDeclined({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -0,0 +1,37 @@
/// Remembers cancelled / declined call ids so a late `video_call_started`
/// push does not show an already-finished ring.
abstract final class VideoCallTombstoneRegistry {
static const Duration defaultTtl = Duration(minutes: 2);
static final Map<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
static void mark(
String? callId, {
DateTime? now,
Duration ttl = defaultTtl,
}) {
final id = _normalize(callId);
if (id == null) return;
final at = now ?? DateTime.now();
_expiresAtByCallId[id] = at.add(ttl);
}
static bool contains(String? callId, {DateTime? now}) {
final id = _normalize(callId);
if (id == null) return false;
_prune(now ?? DateTime.now());
return _expiresAtByCallId.containsKey(id);
}
static void clear() => _expiresAtByCallId.clear();
static void _prune(DateTime now) {
_expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now));
}
static String? _normalize(String? callId) {
final id = callId?.trim().toLowerCase();
if (id == null || id.isEmpty) return null;
return id;
}
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.54+143
version: 1.3.63+152
publish_to: 'none'
environment:
@@ -175,14 +175,14 @@ flutter_intl:
flutter_native_splash:
android: true
ios: true
color: '#FFFFFF'
image_android: 'assets/splash/splash.png'
image_dark_android: 'assets/splash/splash.png'
image_ios: 'assets/splash/splash.png'
image_dark_ios: 'assets/splash/splash.png'
branding_android: 'assets/splash/branding.png'
branding_dark_android: 'assets/splash/branding.png'
color: "#FFFFFF"
image_android: "assets/splash/splash.png"
image_dark_android: "assets/splash/splash.png"
image_ios: "assets/splash/splash.png"
image_dark_ios: "assets/splash/splash.png"
branding_android: "assets/splash/branding.png"
branding_dark_android: "assets/splash/branding.png"
android_12:
image: 'assets/splash/splash.png'
image_dark: 'assets/splash/splash.png'
branding: 'assets/splash/branding.png'
image: "assets/splash/splash.png"
image_dark: "assets/splash/splash.png"
branding: "assets/splash/branding.png"

View File

@@ -61,6 +61,23 @@ void main() {
expect(() => interceptor.onError(error, handler), throwsA(isA<Error>()));
});
test('onError - 405 с missing: [email] без navigator-контекста отклоняет запрос без падения', () async {
final requestOptions = RequestOptions(path: '/test');
final response = Response(requestOptions: requestOptions, statusCode: 405, data: {'type': 'unverificated_phone', 'reason': 'verification_required', 'missing': ['email']});
final error = DioException(requestOptions: requestOptions, response: response, type: DioExceptionType.badResponse);
final handler = MockErrorInterceptorHandler();
when(() => handler.reject(any())).thenReturn(null);
// В тестах AppRouter.navigatorKey.currentContext == null: email-ветка должна это пережить.
interceptor.onError(error, handler);
// onError объявлен как void, дожидаемся завершения асинхронной обработки.
await Future.delayed(const Duration(milliseconds: 100));
verify(() => handler.reject(any())).called(1);
});
test('onError - пропускает другие ошибки', () async {
final requestOptions = RequestOptions(path: '/', method: 'GET', baseUrl: 'http://localhost');
final response = Response(requestOptions: requestOptions, statusCode: 404, data: {});

View File

@@ -0,0 +1,91 @@
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('VerificationRequirement.tryParse', () {
test('missing: [email] — не хватает только почты', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
'missing': ['email'],
});
expect(requirement, isNotNull);
expect(requirement!.needsEmail, isTrue);
expect(requirement.needsPhone, isFalse);
expect(requirement.missing, [VerificationChannel.email]);
});
test('missing: [phone] — не хватает только телефона', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
'missing': ['phone'],
});
expect(requirement, isNotNull);
expect(requirement!.needsPhone, isTrue);
expect(requirement.needsEmail, isFalse);
expect(requirement.missing, [VerificationChannel.phone]);
});
test('missing: [phone, email] — не хватает обоих каналов', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
'missing': ['phone', 'email'],
});
expect(requirement, isNotNull);
expect(requirement!.needsPhone, isTrue);
expect(requirement.needsEmail, isTrue);
});
test('missing отсутствует — legacy-контракт «телефон»', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
});
expect(requirement, isNotNull);
expect(requirement!.needsPhone, isTrue);
expect(requirement.needsEmail, isFalse);
expect(requirement.missing, [VerificationChannel.phone]);
});
test('missing пустой — legacy-контракт «телефон»', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
'missing': [],
});
expect(requirement, isNotNull);
expect(requirement!.needsPhone, isTrue);
expect(requirement.needsEmail, isFalse);
});
test('неизвестное значение в missing игнорируется', () {
final requirement = VerificationRequirement.tryParse({
'type': 'unverificated_phone',
'missing': ['email', 'telegram'],
});
expect(requirement, isNotNull);
expect(requirement!.needsEmail, isTrue);
expect(requirement.needsPhone, isFalse);
expect(requirement.missing, [VerificationChannel.email]);
});
test('data не Map — null', () {
expect(VerificationRequirement.tryParse(null), isNull);
expect(VerificationRequirement.tryParse('unverificated_phone'), isNull);
expect(VerificationRequirement.tryParse(['email']), isNull);
});
test('type не unverificated_phone — null', () {
expect(
VerificationRequirement.tryParse({
'type': 'other',
'missing': ['email'],
}),
isNull,
);
});
});
}

View File

@@ -4,14 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
group('PortionState enum', () {
test('has all expected values', () {
expect(PortionState.values.length, 7);
expect(PortionState.values.length, 10);
expect(PortionState.filledDiet, isNotNull);
expect(PortionState.eatenFromDiet, isNotNull);
expect(PortionState.eaten, isNotNull);
expect(PortionState.eatenDeleted, isNotNull);
expect(PortionState.eatenEdited, isNotNull);
expect(PortionState.commented, isNotNull);
expect(PortionState.commentedFromDiet, isNotNull);
expect(PortionState.reviewedByUser, isNotNull);
expect(PortionState.reviewedFromDiet, isNotNull);
expect(PortionState.unknown, isNotNull);
});
});
group('PortionModel.fromJson', () {
Map<String, dynamic> portionJson({required String state}) => {
'id': 1,
'product_nutritional_value_id': null,
'product_calories_info': null,
'product_name': 'Oatmeal',
'quantity': 1,
'portion': '100g',
'from_diet': true,
'state': state,
};
test('parses commented_from_diet state', () {
final model = PortionModel.fromJson(portionJson(state: 'commented_from_diet'));
expect(model.portionState, PortionState.commentedFromDiet);
});
test('parses reviewed_from_diet state', () {
final model = PortionModel.fromJson(portionJson(state: 'reviewed_from_diet'));
expect(model.portionState, PortionState.reviewedFromDiet);
});
test('does not throw on unknown state from backend', () {
final model = PortionModel.fromJson(portionJson(state: 'some_future_state'));
expect(model.portionState, PortionState.unknown);
});
});

View File

@@ -1,4 +1,5 @@
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';
@@ -51,6 +52,7 @@ void main() {
videoCallRepository: mockRepository,
foregroundCoordinator: foreground,
lifecycleBinding: lifecycleBinding,
resolveHwId: () async => 'test-hw-id-android-01',
);
mockRoom = MockRoom();
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
@@ -82,6 +84,73 @@ void main() {
expect(foreground.startCount, 0);
});
test(
'decline right after hang up does not notify the backend again',
() async {
manager.markActiveCallForTesting(mockRoom);
await manager.endCall();
clearInteractions(mockRepository);
await manager.declineIncomingCall('test-uuid');
verifyNever(mockRepository.notifyCallEnded(any));
verifyNever(
mockRepository.notifyCallDeclined(
conversationUuid: anyNamed('conversationUuid'),
hwId: anyNamed('hwId'),
callId: anyNamed('callId'),
),
);
},
);
test(
'startCall right after hang up does not request a new token',
() async {
manager.markActiveCallForTesting(mockRoom);
await manager.endCall();
clearInteractions(mockRepository);
final started = await manager.startCall(conversationUuid: 'test-uuid');
expect(started, isFalse);
verifyNever(mockRepository.getParticipantToken(any));
},
);
test(
'decline of a call that was never joined notifies the backend via /decline',
() async {
when(
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));
},
);
test('endCall stops foreground service', () async {
manager.markActiveCallForTesting(mockRoom);

View File

@@ -175,6 +175,18 @@ void main() {
});
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
final mockRoom = MockRoom();
when(mockManager.room).thenReturn(mockRoom);
when(
mockManager.onCallScreenVisible(
conversationUuid: anyNamed('conversationUuid'),
counterpartName: anyNamed('counterpartName'),
localParticipantName: anyNamed('localParticipantName'),
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async => true);
cubit.emit(
const VideoCallSessionState(
conversationUuid: 'uuid',
@@ -199,7 +211,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
await cubit.onCallScreenVisible(
conversationUuid: 'uuid',
@@ -231,6 +243,7 @@ void main() {
linkIssue: VideoCallLinkIssue.none,
),
);
return true;
});
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
@@ -244,7 +257,7 @@ void main() {
conversationUuid: 'uuid',
counterpartName: anyNamed('counterpartName'),
localParticipantName: anyNamed('localParticipantName'),
micEnabled: false,
micEnabled: true,
videoEnabled: false,
),
).called(1);
@@ -262,7 +275,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
@@ -297,7 +310,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
@@ -328,7 +341,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
await cubit.onCallScreenVisible(
conversationUuid: 'uuid',
@@ -344,7 +357,7 @@ void main() {
conversationUuid: 'uuid',
counterpartName: anyNamed('counterpartName'),
localParticipantName: anyNamed('localParticipantName'),
micEnabled: false,
micEnabled: true,
videoEnabled: false,
),
).called(1);
@@ -362,7 +375,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
cubit.emit(
const VideoCallSessionState(
@@ -390,7 +403,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
expect(cubit.isCallScreenRouteVisible, isTrue);

View File

@@ -24,8 +24,10 @@ void main() {
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
final expected = VideoCallTokenResponse.fromJson(json);
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenAnswer((_) async => expected);
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenAnswer((_) async => expected);
final result = await repository.getParticipantToken(conversationUuid);
@@ -33,20 +35,28 @@ void main() {
expect(result.token, expected.token);
expect(result.wsUrl, expected.wsUrl);
expect(result.roomName, expected.roomName);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
const conversationUuid = 'conv-uuid-broken';
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenThrow(Exception('No token'));
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenThrow(Exception('No token'));
expect(
() => repository.getParticipantToken(conversationUuid),
throwsA(isA<Exception>()),
);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
@@ -91,6 +101,60 @@ void main() {
expect(captured.single as Map<String, dynamic>, isEmpty);
});
test('notifyCallEnded - передаёт call_id когда он известен', () async {
const conversationUuid = 'conv-uuid-123';
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
final expected = VideoCallEndedResponse.fromJson(json);
when(mockService.notifyCallEnded(
conversationUuid: conversationUuid,
body: anyNamed('body'),
)).thenAnswer((_) async => expected);
await repository.notifyCallEnded(conversationUuid, callId: 'call-1');
final captured = verify(
mockService.notifyCallEnded(
conversationUuid: conversationUuid,
body: captureAnyNamed('body'),
),
).captured.single as Map<String, dynamic>;
expect(captured, <String, dynamic>{'call_id': 'call-1'});
});
test('notifyCallDeclined - передаёт hw_id и call_id', () async {
const conversationUuid = 'conv-uuid-123';
final json = FixtureReader.readAsMap(
'video_calls/video_call_decline_response.json',
);
final expected = VideoCallDeclineResponse.fromJson(json);
when(mockService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: anyNamed('body'),
)).thenAnswer((_) async => expected);
final result = await repository.notifyCallDeclined(
conversationUuid: conversationUuid,
hwId: 'device-android-001',
callId: 'call-1',
);
expect(result.sent, isTrue);
expect(result.devicesNotified, 1);
final captured = verify(
mockService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: captureAnyNamed('body'),
),
).captured.single as Map<String, dynamic>;
expect(captured, <String, dynamic>{
'hw_id': 'device-android-001',
'call_id': 'call-1',
});
});
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
const conversationUuid = 'conv-uuid-broken';

View File

@@ -1,9 +1,15 @@
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const conversation = '11111111-1111-4111-8111-111111111111';
const otherConversation = '22222222-2222-4222-8222-222222222222';
const callKitChannel = MethodChannel('flutter_callkit_incoming');
group('VideoCallEndedPushHandler.callMatchesConversation', () {
test('matches the conversation stored as the call handle', () {
@@ -32,10 +38,7 @@ void main() {
});
test('leaves a call from another chat alone', () {
const call = CallKitParams(
id: 'other-id',
handle: '22222222-2222-4222-8222-222222222222',
);
const call = CallKitParams(id: 'other-id', handle: otherConversation);
expect(
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
@@ -43,4 +46,139 @@ void main() {
);
});
});
group('VideoCallEndedPushHandler.callMatches', () {
test('matches by call_id and ignores conversation fallback', () {
const call = CallKitParams(
id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
handle: conversation,
extra: <String, dynamic>{
'conversationUuid': conversation,
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
},
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
),
isTrue,
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: 'stale-call-id',
),
isFalse,
);
});
});
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
final calls = <MethodCall>[];
late List<Map<String, Object?>> activeCalls;
setUp(() {
calls.clear();
VideoCallRemoteEndRegistry.clear();
activeCalls = <Map<String, Object?>>[
<String, Object?>{
'id': 'ringing-id',
'handle': conversation,
'extra': <String, Object?>{'conversationUuid': conversation},
},
<String, Object?>{
'id': 'other-id',
'handle': otherConversation,
'extra': <String, Object?>{'conversationUuid': otherConversation},
},
];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(callKitChannel, (call) async {
calls.add(call);
if (call.method == 'activeCalls') return activeCalls;
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(callKitChannel, null);
});
test('ends only the matching call and marks it as a remote end', () async {
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversation,
);
final ended = calls
.where((call) => call.method == 'endCall')
.map((call) => (call.arguments as Map)['id'])
.toList();
expect(ended, <String>['ringing-id']);
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
});
test('ends only the matching call_id', () async {
activeCalls = <Map<String, Object?>>[
<String, Object?>{
'id': 'call-a',
'handle': conversation,
'extra': <String, Object?>{
'conversationUuid': conversation,
'call_id': 'call-a',
},
},
<String, Object?>{
'id': 'call-b',
'handle': conversation,
'extra': <String, Object?>{
'conversationUuid': conversation,
'call_id': 'call-b',
},
},
];
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversation,
callId: 'call-a',
);
final ended = calls
.where((call) => call.method == 'endCall')
.map((call) => (call.arguments as Map)['id'])
.toList();
expect(ended, <String>['call-a']);
expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse);
});
test(
'without a conversation ends every call and marks all of them',
() async {
await VideoCallEndedPushHandler.dismissIncomingCallUi();
expect(calls.map((call) => call.method), contains('endAllCalls'));
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue);
},
);
test('dismissCallKitOnly ends the matching CallKit call', () async {
await VideoCallEndedPushHandler.dismissCallKitOnly(
conversationUuid: conversation,
);
final ended = calls
.where((call) => call.method == 'endCall')
.map((call) => (call.arguments as Map)['id'])
.toList();
expect(ended, <String>['ringing-id']);
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
});
});
}

View File

@@ -74,7 +74,16 @@ void main() {
expect(shown.single.ios?.configureAudioSession, isFalse);
expect(shown.single.ios?.audioSessionActive, isFalse);
expect(shown.single.ios?.audioSessionMode, 'videoChat');
expect(shown.single.ios?.ringtonePath, 'system_ringtone_default');
expect(shown.single.ios?.ringtonePath, isNull);
expect(shown.single.missedCallNotification?.showNotification, isTrue);
expect(
shown.single.missedCallNotification?.subtitle,
VideoCallIncomingPushPresenter.iosMissedCallSubtitle,
);
expect(
shown.single.missedCallNotification?.callbackText,
VideoCallIncomingPushPresenter.iosMissedCallCallbackText,
);
});
test('iOS stops polling when the push claims the call', () async {
@@ -122,6 +131,25 @@ void main() {
expect(shown, isEmpty);
});
test('Android does not open a second CallKit UI', () async {
final shown = <CallKitParams>[];
final native = <VideoCallPushPayload>[];
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: false,
showAndroidIncoming: (incoming) async {
native.add(incoming);
},
showCallkitIncoming: (params) async {
shown.add(params);
},
);
expect(shown, isEmpty);
expect(native, [payload]);
});
test(
'Android shows the incoming call without waiting for a VoIP claim',
() async {

View File

@@ -96,6 +96,92 @@ void main() {
);
});
test('a decline just after leaving does not report the end again', () {
final leftAt = DateTime(2026, 10, 16, 12);
expect(
incomingDeclineShouldNotifyBackend(
leftAt: leftAt,
now: leftAt.add(const Duration(seconds: 5)),
),
isFalse,
);
expect(
incomingDeclineShouldNotifyBackend(
leftAt: leftAt,
now: leftAt.add(incomingDeclineNotifySuppression),
),
isTrue,
);
expect(
incomingDeclineShouldNotifyBackend(leftAt: null, now: leftAt),
isTrue,
);
});
test('a token is not requested again right after leaving', () {
final leftAt = DateTime(2026, 10, 16, 12);
expect(
mayRequestParticipantToken(
sessionPermitted: false,
leftAt: leftAt,
now: leftAt.add(const Duration(seconds: 2)),
),
isFalse,
);
expect(
mayRequestParticipantToken(
sessionPermitted: true,
leftAt: leftAt,
now: leftAt.add(const Duration(seconds: 2)),
),
isTrue,
);
expect(
mayRequestParticipantToken(
sessionPermitted: false,
leftAt: null,
now: leftAt,
),
isTrue,
);
});
test(
'an ended VoIP push is marked so its CallKit close is not a decline',
() {
expect(
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': true}),
isTrue,
);
expect(
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': 'true'}),
isTrue,
);
expect(callKitExtraMarksEndedPush(<String, dynamic>{}), isFalse);
expect(
callKitDeclineIsEndedPush(
cachedExtra: <String, dynamic>{},
eventExtra: <String, dynamic>{'endedPush': true},
),
isTrue,
);
expect(
callKitDeclineIsEndedPush(
cachedExtra: <String, dynamic>{'endedPush': true},
eventExtra: <String, dynamic>{},
),
isTrue,
);
expect(
callKitDeclineIsEndedPush(
cachedExtra: <String, dynamic>{},
eventExtra: <String, dynamic>{},
),
isFalse,
);
},
);
test('keeps a connection-loss screen when the link drops', () {
expect(
shouldForceEndCallAfterRoomDisconnect(

View File

@@ -60,6 +60,24 @@ void main() {
expect(payload!.conversationUuid, 'abc-123');
});
test('parses ended video call push with call_id and reason', () {
final payload = VideoCallEndedPushPayload.tryParse({
'type': 'video_call_ended',
'conversation_uuid': 'abc-123',
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
'reason': 'declined',
'accepted_device_id': 'device-a',
'accepted_client': 'android',
});
expect(payload, isNotNull);
expect(payload!.conversationUuid, 'abc-123');
expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
expect(payload.reason?.name, 'declined');
expect(payload.acceptedDeviceId, 'device-a');
expect(payload.acceptedClient, 'android');
});
test('parses message id for a stable CallKit uuid', () {
final payload = VideoCallPushPayload.tryParse({
'type': 'video_call_started',
@@ -77,6 +95,28 @@ void main() {
);
});
test('prefers call_id as CallKit id when present', () {
final payload = VideoCallPushPayload.tryParse({
'type': 'video_call_started',
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
'message_id': '42',
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
'expires_at': '2026-04-27T14:31:00+00:00',
});
expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
expect(payload?.expiresAt, isNotNull);
expect(
VideoCallKitId.resolve(
callId: payload!.callId,
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
isIos: true,
),
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
);
});
test('ignores regular chat push', () {
expect(
VideoCallPushPayload.tryParse({

View File

@@ -0,0 +1,44 @@
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(VideoCallRemoteEndRegistry.clear);
group('VideoCallRemoteEndRegistry', () {
test('remembers a marked id regardless of case and whitespace', () {
VideoCallRemoteEndRegistry.mark(' 11111111-1111-4111-8111-111111111111 ');
expect(
VideoCallRemoteEndRegistry.contains(
'11111111-1111-4111-8111-111111111111'.toUpperCase(),
),
isTrue,
);
});
test('does not know an id that was never marked', () {
expect(VideoCallRemoteEndRegistry.contains('other'), isFalse);
expect(VideoCallRemoteEndRegistry.contains(''), isFalse);
});
test('outlives the 45 s ring timeout and expires after the ttl', () {
final markedAt = DateTime(2026, 10, 19, 12);
VideoCallRemoteEndRegistry.mark('call', now: markedAt);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(const Duration(seconds: 50)),
),
isTrue,
);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(VideoCallRemoteEndRegistry.ttl),
),
isFalse,
);
});
});
}

View File

@@ -21,23 +21,32 @@ void main() {
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
final expected = VideoCallTokenResponse.fromJson(json);
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenAnswer((_) async => expected);
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenAnswer((_) async => expected);
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
final result = await mockService.getParticipantToken(
conversationUuid: conversationUuid,
);
expect(result, isA<VideoCallTokenResponse>());
expect(result.token, expected.token);
expect(result.wsUrl, expected.wsUrl);
expect(result.roomName, expected.roomName);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getParticipantToken - пробрасывает ошибку', () async {
const conversationUuid = 'conv-uuid-error';
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenThrow(Exception('Network error'));
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenThrow(Exception('Network error'));
expect(
() => mockService.getParticipantToken(conversationUuid: conversationUuid),

View File

@@ -0,0 +1,22 @@
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
tearDown(VideoCallTombstoneRegistry.clear);
test('marks a call id so a late incoming can be suppressed', () {
const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b';
final now = DateTime.utc(2026, 10, 21, 10);
VideoCallTombstoneRegistry.mark(callId, now: now);
expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue);
expect(
VideoCallTombstoneRegistry.contains(
callId,
now: now.add(const Duration(minutes: 3)),
),
isFalse,
);
});
}

View File

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