Compare commits

...

29 Commits

Author SHA1 Message Date
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
misha
6d2935400a feat(video_calls): ios voip push, mix and crushes fix 2026-10-16 13:49:48 +03: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
misha
ff17b813ac feat(ios): env migration for ios 27 2026-10-16 08:26:26 +03: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
misha
9288a5c849 version changed 2026-10-15 15:22:04 +03:00
misha
4ef174ebde feat(video_calls): repeated calls after call termination fix 2026-10-15 15:21:38 +03:00
misha
1901248539 feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching 2026-10-15 14:06:37 +03:00
b2e700a4eb Merge pull request 'fix: ci/cd' (#1074) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1074
2026-10-14 10:17:37 +00:00
61f1b76f99 Merge pull request 'issue-1062' (#1068) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1068
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-14 09:53:01 +00:00
572a6f1943 Merge pull request 'update(doc): bug report issue template, desc field added' (#1073) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1073
2026-10-09 09:47:54 +00:00
Anatoliy Poloz
a6ae9abb57 update(doc): bug report issue template, desc field added 2026-10-09 12:47:22 +03:00
431354170e Merge pull request 'description field added to feature issue template' (#1072) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1072
2026-10-09 09:43:28 +00:00
Anatoliy Poloz
c38e82f558 description field added to feature issue template 2026-10-09 12:43:00 +03:00
afd6f9a93a Merge pull request 'update bug issue template' (#1071) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1071
2026-10-09 09:34:46 +00:00
Anatoliy Poloz
641eb4d2e2 update bug issue template 2026-10-09 12:34:08 +03:00
74c66c9600 Merge pull request 'feat(doc): issue templates' (#1070) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1070
2026-10-09 09:12:18 +00:00
Anatoliy Poloz
73e89ff7a9 feat(doc): issue templates 2026-10-09 12:11:30 +03:00
51 changed files with 2907 additions and 342 deletions

View File

@@ -0,0 +1,62 @@
name: "🐞 Bug Report"
description: "Report a bug to help us improve"
title: "[Bug]: "
labels: ["bug"]
body:
- type: dropdown
id: stand
attributes:
label: "Stand"
description: "Select the environment where the bug occurs"
options:
- "prod"
- "stage-dev2"
- "local"
- "all"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: "Platform"
description: "Select the platform where the bug occurs"
options:
- "Android"
- "iPhone"
- "Huawei"
- "All"
validations:
required: true
- type: input
id: device_model
attributes:
label: "Device Model"
description: "Optional. Specify the device where the bug occurs"
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
- type: textarea
id: reproduction_steps
attributes:
label: "Steps to Reproduce"
description: "Clear numbered steps"
placeholder: "1. Click X\n2. Navigate to Y..."
validations:
required: true
- type: textarea
id: expected_actual
attributes:
label: "Expected vs Actual Behavior"
description: "What should happen vs what happens"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "Дополнительная информация описывающая проблему"
validations:
required: true

View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: "💬 Community Support"
url: "https://pepe.eatv.io/channel/general"
about: "Ask questions here"

View File

@@ -0,0 +1,26 @@
name: "✨ Feature Request"
description: "Suggest an idea for this project"
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: use_case
attributes:
label: "Use Case"
description: "Problem solved or value added"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "Дополнительная информация описывающая функциональность"
validations:
required: true
- type: dropdown
id: priority
attributes:
label: "Priority Level"
options: ["Low", "Medium", "High"]

View File

@@ -69,6 +69,7 @@ android {
}
dependencies {
testImplementation 'junit:junit:4.13.2'
implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'

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

@@ -8,6 +8,10 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
VideoCallPushBridge.silenceIncomingCall(context)
}
if (data != null) {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)

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

@@ -0,0 +1,15 @@
package com.eatvioapp.app
/**
* Call actions that must stop the incoming ringtone.
* Accept is included: answering the call is not the same as ending it,
* but the ringing player is a separate object from the in-call audio.
*/
internal object IncomingCallRingtonePolicy {
fun actionStopsRingtone(action: String): Boolean {
return action.contains("ACTION_CALL_ACCEPT") ||
action.contains("ACTION_CALL_DECLINE") ||
action.contains("ACTION_CALL_ENDED") ||
action.contains("ACTION_CALL_TIMEOUT")
}
}

View File

@@ -65,6 +65,21 @@ class MainActivity: FlutterActivity() {
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,10 +4,12 @@ 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
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
@@ -17,14 +19,38 @@ import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
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 WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
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
/**
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
* Incoming pushes start a different player, so accept/decline never reached it
* and the ringtone kept looping until the process died.
*/
private val incomingCallEventCallback = object : CallkitEventCallback {
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
val context = storedAppContext ?: return
Log.i(TAG, "incoming call event=$event, stopping ringtone")
silenceIncomingCall(context)
}
}
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"]
@@ -89,18 +115,133 @@ object VideoCallPushBridge {
fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
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: conversation=$callId")
// 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 (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)
}
}
}
/**
* Stops the ringtone that [showIncomingCall] started and removes the incoming
* notification. Safe to call more than once.
*/
fun silenceIncomingCall(context: Context) {
val appContext = context.applicationContext
storedAppContext = appContext
Log.i(TAG, "silenceIncomingCall")
playbackGeneration.incrementAndGet()
val stop = Runnable {
synchronized(playbackLock) {
stopPlaybackLocked(appContext)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
stop.run()
} else {
mainHandler.post(stop)
}
}
/**
* 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 dismissAllIncomingCalls(context: Context) {
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
stopPlaybackLocked(appContext)
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: 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)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone()
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dismiss.run()
} else {
mainHandler.post(dismiss)
}
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
callbackRegistered = true
}
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
stopPlaybackLocked(appContext)
val soundManager = CallkitSoundPlayerManager(appContext)
val notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager
activeNotificationManager = notificationManager
activeCallBundle = callData
notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData)
@@ -109,46 +250,78 @@ object VideoCallPushBridge {
} catch (error: Exception) {
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
}
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) {
if (generation != playbackGeneration.get()) return@Runnable
Log.i(TAG, "incoming ringtone timed out")
stopPlaybackLocked(appContext)
}
}
ringtoneTimeout = timeout
mainHandler.postDelayed(timeout, durationMs)
}
}
fun dismissAllIncomingCalls(context: Context) {
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
/**
* 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)
}
}
stopIncomingRingtone(appContext)
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)
}
}
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
for (bundle in bundles) {
private fun stopPlaybackLocked(appContext: Context) {
cancelRingtoneTimeoutLocked()
releaseScreenWakeLockLocked()
val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
if (notificationManager != null && bundle != null) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone(appContext)
scheduleMissedCallCleanup(notificationManager, bundles)
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
ringtoneTimeout = null
}
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
@@ -161,11 +334,19 @@ object VideoCallPushBridge {
return CallkitNotificationManager(context, soundManager)
}
private fun stopIncomingRingtone(context: Context) {
activeSoundManager?.stop()
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
private fun stopIncomingRingtone() {
try {
activeSoundManager?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: active player", error)
}
try {
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
}
}
/**

View File

@@ -0,0 +1,45 @@
package com.eatvioapp.app
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class IncomingCallRingtonePolicyTest {
@Test
fun answerDeclineEndAndTimeoutStopTheRingtone() {
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
),
)
}
@Test
fun incomingAndStartActionsKeepTheRingtonePlaying() {
assertFalse(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
),
)
assertFalse(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
),
)
}
}

View File

@@ -11,6 +11,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -37,6 +38,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -115,6 +117,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
);
@@ -300,6 +303,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -15,12 +15,32 @@ 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.
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
/// in-app path can see it and not report the same call again.
private var reservedIncomingCallIds: [String: Date] = [:]
private let incomingCallClaimLock = NSLock()
private let incomingCallClaimTtl: TimeInterval = 90
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
private var voipCallReporterStorage: VoipCallReporter?
private var audioRestartGeneration = 0
private var voipCallReporter: VoipCallReporter {
if let voipCallReporterStorage {
return voipCallReporterStorage
}
let reporter = VoipCallReporter()
voipCallReporterStorage = reporter
return reporter
}
override func application(
_ application: UIApplication,
@@ -31,10 +51,12 @@ import flutter_foreground_task
GeneratedPluginRegistrant.register(with: registry)
}
let mainQueue = DispatchQueue.main
voipRegistry = PKPushRegistry(queue: mainQueue)
// Not the main queue. Flutter startup blocks main, and PushKit aborts the
// process if this push is not reported before that returns.
voipRegistry = PKPushRegistry(queue: voipPushQueue)
voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
configureWebRTCAudioSession()
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
@@ -42,50 +64,81 @@ import flutter_foreground_task
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: controller.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
default:
result(FlutterMethodNotImplemented)
}
}
// WebRTC must not activate the session itself. CallKit and the in-app
// call both own AVAudioSession, and a second activation drops the mic.
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.isAudioEnabled = false
let callAudioChannel = FlutterMethodChannel(
name: callAudioSessionChannelName,
binaryMessenger: controller.binaryMessenger
)
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "activate":
self.activateInAppCallAudioSession()
result(nil)
case "deactivate":
self.deactivateInAppCallAudioSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
registerCallChannels(from: window?.rootViewController as? FlutterViewController)
return launched
}
func registerCallChannels(from controller: FlutterViewController?) {
guard voipDiagnosticsChannel == nil else { return }
let flutterController = controller ?? flutterViewControllerFromScenes()
guard let flutterController else { return }
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: flutterController.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
case "isIncomingCallClaimed":
result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments)))
case "reserveIncomingCall":
result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments)))
default:
result(FlutterMethodNotImplemented)
}
}
return launched
let callAudioChannel = FlutterMethodChannel(
name: callAudioSessionChannelName,
binaryMessenger: flutterController.binaryMessenger
)
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "activate":
self.activateInAppCallAudioSession()
result(nil)
case "restart":
self.restartInAppCallAudioUnit()
result(nil)
case "deactivate":
self.deactivateInAppCallAudioSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func flutterViewControllerFromScenes() -> FlutterViewController? {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for sceneWindow in windowScene.windows {
if let controller = sceneWindow.rootViewController as? FlutterViewController {
return controller
}
}
}
return nil
}
@available(iOS 8.0, *)
@@ -109,6 +162,14 @@ import flutter_foreground_task
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
handleOpenURL(
url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
)
}
@discardableResult
func handleOpenURL(_ url: URL, sourceApplication: String?) -> Bool {
if url.scheme == appUrlScheme {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
@@ -142,7 +203,7 @@ import flutter_foreground_task
return YXLSdk.shared.handleOpen(
url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
sourceApplication: sourceApplication
)
}
@@ -175,6 +236,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,
@@ -223,16 +304,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
}
@@ -338,7 +434,9 @@ import flutter_foreground_task
events = Array(events.suffix(20))
}
defaults.set(events, forKey: voipDiagnosticsStorageKey)
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
DispatchQueue.main.async { [weak self] in
self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
}
}
private func drainVoipDiagnostics() -> [[String: Any]] {
@@ -348,6 +446,167 @@ import flutter_foreground_task
return events
}
private func isEndedVideoCallMarker(_ marker: String) -> Bool {
if marker == "video_call_ended" || marker == "video_call_end" {
return true
}
return marker.contains("video_call")
&& (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel"))
}
private func incomingCallId(from arguments: Any?) -> String {
let raw: String?
if let id = arguments as? String {
raw = id
} else if let map = arguments as? [String: Any] {
raw = map["id"] as? String
} else {
raw = nil
}
return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
}
/// Caller must hold `incomingCallClaimLock`.
private func pruneIncomingCallClaims(now: Date = Date()) {
reservedIncomingCallIds = reservedIncomingCallIds.filter {
now.timeIntervalSince($0.value) < incomingCallClaimTtl
}
}
private func activeIncomingCallIds() -> Set<String> {
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() })
}
private func isIncomingCallClaimed(_ id: String) -> Bool {
let normalized = id.lowercased()
guard !normalized.isEmpty else { return false }
let reserved = incomingCallClaimLock.withLock { () -> Bool in
pruneIncomingCallClaims()
return reservedIncomingCallIds[normalized] != nil
}
if reserved {
return true
}
return activeIncomingCallIds().contains(normalized)
}
/// Returns false when this id is already reserved. The VoIP push still
/// reports the call; this only stops the in-app path from reporting it too.
private func reserveIncomingCall(_ id: String) -> Bool {
let normalized = id.lowercased()
guard !normalized.isEmpty else { return false }
return incomingCallClaimLock.withLock {
pruneIncomingCallClaims()
if reservedIncomingCallIds[normalized] != nil {
return false
}
reservedIncomingCallIds[normalized] = Date()
return true
}
}
private func releaseIncomingCallClaim(_ id: String) {
incomingCallClaimLock.withLock {
_ = reservedIncomingCallIds.removeValue(forKey: id.lowercased())
}
}
/// 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) {
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
releaseIncomingCallClaim(reportedId)
return
}
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
var ids: [String] = []
func append(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) {
return
}
ids.append(normalized)
}
append(reportedId)
for call in plugin.activeCalls() {
let callId = (call["id"] as? String) ?? ""
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
let sameConversation = !conversation.isEmpty && (
handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
)
if sameId || sameConversation {
append(callId)
}
}
for id in ids {
let endData = flutter_callkit_incoming.Data(
id: id,
nameCaller: "Итвио",
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
}
endData.extra = endExtra as NSDictionary
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
}
private func activeCallId(forConversation conversationUuid: String) -> String? {
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !conversation.isEmpty else { return nil }
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
for call in calls {
let callId = (call["id"] as? String) ?? ""
guard !callId.isEmpty else { continue }
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
{
return callId
}
}
return nil
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map: [String: Any]
if let typed = extra as? [String: Any] {
map = typed
} else 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 ""
}
for key in ["conversationUuid", "conversation_uuid"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return ""
}
func pushRegistry(
_ registry: PKPushRegistry,
@@ -355,11 +614,15 @@ import flutter_foreground_task
for type: PKPushType
) {
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
DispatchQueue.main.async {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
}
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
DispatchQueue.main.async {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
}
func pushRegistry(
@@ -384,8 +647,8 @@ import flutter_foreground_task
]
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
if pushMarkers.contains(where: { $0.contains("video_call") })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
if isEndedPush {
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
@@ -393,9 +656,6 @@ import flutter_foreground_task
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
}
let rawNameCaller = (dict["nameCaller"] as? String)
@@ -420,14 +680,20 @@ import flutter_foreground_task
let parsedMessageId = voipMessageId(dict["message_id"])
let id: String
let callKitIdSourceName: String
if let derived = derivedCallKitId(
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
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
id = UUID().uuidString.lowercased()
callKitIdSourceName = "generated"
}
let isVideo = (dict["isVideo"] as? Bool)
@@ -464,6 +730,18 @@ 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 {
extra["endedPush"] = true
}
// An ended push with no ringing call still has to be reported. That
// report is closed at once, so it must not leave a trace anywhere.
let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation"
let data = flutter_callkit_incoming.Data(
id: id,
@@ -471,8 +749,27 @@ 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
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"
// 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)"
@@ -490,53 +787,102 @@ import flutter_foreground_task
]
)
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
]
)
completion()
return
reportVoipPushToCallKit(
data,
callKitId: id,
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
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,
endAfterReport: Bool,
endImmediately: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
let finish = { self.voipPushQueue.async { gate.call() } }
voipPushQueue.asyncAfter(deadline: .now() + 1.5) {
finish()
}
callkitPlugin.showCallkitIncoming(
data,
fromPushKit: true
) {
let activeCalls = callkitPlugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
NSLog(
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
)
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
]
)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let delayedActiveCalls = callkitPlugin.activeCalls()
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
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
)
}
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
let activeCalls = plugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_active_calls_delayed",
"callkit_report_completed",
data: [
"callKitId": id,
"callKitId": callKitId,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": delayedActiveIds,
"activeCallsCount": delayedActiveCalls.count,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
"endAfterReport": endAfterReport,
]
)
}
completion()
finish()
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
return
}
NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": callKitId,
"conversationUuid": conversationUuid,
]
)
guard let uuid = UUID(uuidString: data.uuid) else {
finish()
return
}
voipCallReporter.report(
uuid: uuid,
callerName: data.nameCaller,
hasVideo: data.type > 0
) { _ in
if endAfterReport {
self.voipCallReporter.provider.reportCall(
with: uuid,
endedAt: Date(),
reason: .remoteEnded
)
}
afterReport()
}
}
@@ -563,6 +909,7 @@ import flutter_foreground_task
func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
action.fulfill()
}
@@ -574,50 +921,76 @@ import flutter_foreground_task
func didActivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.audioSessionDidActivate(audioSession)
applyCallAudioConfiguration(active: false)
rtcSession.isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidDeactivate(audioSession)
// The audio unit is stopped, but isAudioEnabled stays true. A later
// assignment of true is then a no-op and the microphone never restarts.
rtcSession.isAudioEnabled = false
guard resumeAudioAfterCallKitEnds else { return }
DispatchQueue.main.async { [weak self] in
guard let self, self.resumeAudioAfterCallKitEnds else { return }
self.activateInAppCallAudioSession()
self.restartInAppCallAudioUnit()
}
}
func providerDidReset() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
}
private func configureWebRTCAudioSession() {
let configuration = callAudioConfiguration()
RTCAudioSessionConfiguration.setWebRTC(configuration)
}
private func prepareCallAudioCategory() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
let configuration = callAudioConfiguration()
do {
try rtcSession.setConfiguration(configuration)
} catch {
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
}
applyCallAudioConfiguration(active: false)
}
private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
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.
private func restartInAppCallAudioUnit() {
audioRestartGeneration += 1
let generation = audioRestartGeneration
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.isAudioEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self, generation == self.audioRestartGeneration else { return }
self.activateInAppCallAudioSession()
}
}
private func applyCallAudioConfiguration(active: Bool) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
rtcSession.isAudioEnabled = true
try rtcSession.setConfiguration(callAudioConfiguration(), active: active)
} catch {
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)")
}
}
private func deactivateInAppCallAudioSession() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false
rtcSession.lockForConfiguration()
@@ -636,7 +1009,61 @@ import flutter_foreground_task
.allowBluetooth,
.defaultToSpeaker,
]
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
// videoChat is the speakerphone mode for a video call. voiceChat keeps
// the input on the receiver, so the mic is silent once audio goes to
// the loudspeaker.
configuration.mode = AVAudioSession.Mode.videoChat.rawValue
// Voice processing rejects 44.1 kHz and throws from AVAudioEngine.
configuration.sampleRate = 48000
configuration.ioBufferDuration = 0.02
configuration.inputNumberOfChannels = 1
configuration.outputNumberOfChannels = 1
return configuration
}
}
/// Reports a VoIP push when the Flutter CallKit plugin is not registered yet.
private final class VoipPushCompletion {
private var finished = false
private let completion: () -> Void
init(_ completion: @escaping () -> Void) {
self.completion = completion
}
func call() {
if finished { return }
finished = true
completion()
}
}
private final class VoipCallReporter: NSObject, CXProviderDelegate {
let provider: CXProvider
override init() {
let configuration = CXProviderConfiguration(localizedName: "Итвио")
configuration.supportsVideo = true
configuration.maximumCallGroups = 1
configuration.maximumCallsPerCallGroup = 1
configuration.supportedHandleTypes = [.generic]
provider = CXProvider(configuration: configuration)
super.init()
provider.setDelegate(self, queue: nil)
}
func report(
uuid: UUID,
callerName: String,
hasVideo: Bool,
completion: @escaping (Error?) -> Void
) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.localizedCallerName = callerName
update.hasVideo = hasVideo
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
}
func providerDidReset(_ provider: CXProvider) {}
}

View File

@@ -41,6 +41,27 @@
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>

View File

@@ -0,0 +1,86 @@
import Flutter
import UIKit
import YandexLoginSDK
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
/// here after the scene creates the Flutter view.
class SceneDelegate: FlutterSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
registerCallChannels()
handleConnectionOptions(connectionOptions)
}
override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene)
registerCallChannels()
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
var handledCustomURL = false
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
for context in URLContexts {
if appDelegate.handleOpenURL(
context.url,
sourceApplication: context.options.sourceApplication
) {
handledCustomURL = true
}
}
}
if !handledCustomURL {
super.scene(scene, openURLContexts: URLContexts)
}
}
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if YXLSdk.shared.processUserActivity(userActivity) {
return
}
super.scene(scene, continue: userActivity)
}
private func registerCallChannels() {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.registerCallChannels(from: flutterViewController)
}
private var flutterViewController: FlutterViewController? {
if let controller = window?.rootViewController as? FlutterViewController {
return controller
}
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for sceneWindow in windowScene.windows {
if let controller = sceneWindow.rootViewController as? FlutterViewController {
return controller
}
}
}
return nil
}
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
for context in options.urlContexts {
_ = appDelegate.handleOpenURL(
context.url,
sourceApplication: context.options.sourceApplication
)
}
for activity in options.userActivities {
if YXLSdk.shared.processUserActivity(activity) {
continue
}
}
}
}

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
@@ -31,20 +35,50 @@ class AppConfig {
void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls;
_persistBaseUrl(baseUrls);
}
void _persistBaseUrl(String baseUrl) {
if (!sl.isRegistered<Prefs>()) {
return;
}
final normalized = baseUrl.trim();
if (normalized.isEmpty) {
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
}
/// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List<BlocProvider> get globalBlocProviders => [
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
BlocProvider<VideoCallSessionCubit>.value(
value: sl<VideoCallSessionCubit>(),
),
BlocProvider<NewMessagesCubit>(
lazy: true,
create: (context) => sl<NewMessagesCubit>(),
),
BlocProvider<NotificationsCubit>(
lazy: true,
create: (context) => sl<NotificationsCubit>(),
),
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
BlocProvider<ProfileAvatarCubit>(
lazy: true,
create: (context) => sl<ProfileAvatarCubit>(),
),
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
BlocProvider<InternetConnectionCubit>(
lazy: false,
create: (context) => sl<InternetConnectionCubit>(),
),
BlocProvider<MarketingCubit>(
lazy: true,
create: (context) => sl<MarketingCubit>(),
),
];
/// Делегаты локализации
@@ -56,5 +90,7 @@ class AppConfig {
];
/// Поддерживаемые локали
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
List<Locale> get supportedLocales => [
const Locale.fromSubtags(languageCode: 'ru'),
];
}

View File

@@ -0,0 +1,16 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
/// Подставляет сохранённый base URL до создания HTTP-клиента.
void applyCachedBaseUrl() {
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
return;
}
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
if (cached == null || cached.isEmpty) {
return;
}
sl<AppConfig>().setBaseUrl(cached);
}

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,4 +1,5 @@
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cached_base_url.dart';
import 'package:eatvio_app/core/di/app_config_registration.dart';
import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/core/di/external_registrations.dart';
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
// Ожидание инициализации асинхронных зависимостей
await sl.getAsync<Prefs>();
// До создания Dio и любых запросов к бэкенду.
applyCachedBaseUrl();
// Регистрация сервисов
ServiceRegistrations.registerAll();
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
CubitRegistrations.registerAll();
// Регистрация WebSocket менеджера
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()),
);
}

View File

@@ -65,7 +65,11 @@ class PushNavigationHandler {
'PushNavigationHandler: dismiss incoming call UI, source=$source',
name: 'PushNavigationHandler',
);
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
),
);
return;
}

View File

@@ -1 +1,8 @@
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
enum PrefsKeys {
hasAnonymousTokenBeenSent,
welcomeScreenWasShowed,
registrationDate,
marketingBottomSheetHasBeenShown,
isEmailVerificationSkipped,
customBaseUrl,
}

View File

@@ -27,7 +27,7 @@ class ChatsCubit extends Cubit<ChatsState> {
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -230,7 +230,10 @@ class ChatsCubit extends Cubit<ChatsState> {
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi());
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
);
return;
}
if (isOwn || kind != VideoCallChatSignalKind.started) return;
@@ -257,10 +260,16 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
Future<void> _dismissIncomingVideoCallUi() async {
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
await (dismissIncomingVideoCall ??
VideoCallEndedPushHandler.dismissIncomingCallUi)();
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversationUuid,
);
} catch (e, st) {
log(
'ChatsCubit: failed to dismiss incoming video call: $e',

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

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
@@ -8,6 +9,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.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/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_task_handler.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
@@ -37,6 +39,12 @@ class VideoCallManager {
_audioSession =
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
if (Platform.isIOS) {
// LiveKit rewrites the audio session whenever a remote audio track
// appears. That second configuration stops the microphone and does
// not start it again.
lk.Hardware.instance.setAutomaticConfigurationEnabled(enable: false);
}
}
final VideoCallRepository _videoCallRepository;
@@ -46,7 +54,10 @@ class VideoCallManager {
final VideoCallAudioSessionCoordinator _audioSession;
static const lk.AudioCaptureOptions _micCaptureOptions =
lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
lk.AudioCaptureOptions(
stopAudioCaptureOnMute: false,
voiceIsolation: false,
);
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
static const Duration _mutedMicOverlayMinInterval = Duration(
milliseconds: 2600,
@@ -65,6 +76,9 @@ class VideoCallManager {
num? _lastMutedMicTotalEnergy;
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
bool _forceEndRequested = false;
bool _sessionMayRequestToken = false;
final Map<String, DateTime> _leftConversationAt = {};
final Set<String> _callAcceptNotifiedConversationUuids = {};
bool _callStageEntered = false;
bool _joinInProgress = false;
@@ -86,6 +100,8 @@ class VideoCallManager {
String? get counterpartName => _snapshot.counterpartName;
String? get localParticipantName => _snapshot.localParticipantName;
bool get callEndedNotified => _callEndedNotified;
bool get isActiveCall =>
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
_callStageEntered &&
@@ -164,13 +180,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) {
@@ -185,7 +217,7 @@ class VideoCallManager {
);
_ensureLifecycleAttached();
unawaited(_startCallAudioSession());
return;
return true;
}
if (!_callStageEntered &&
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
@@ -199,14 +231,16 @@ class VideoCallManager {
),
);
_ensureLifecycleAttached();
return;
return true;
}
}
await endCall();
_sessionMayRequestToken = true;
_resetSessionFlagsForNewRoom();
_callEndedNotified = false;
_forceEndRequested = false;
_liveKitDisposed = false;
_callStageEntered = false;
_room = lk.Room(
@@ -240,9 +274,10 @@ class VideoCallManager {
);
await _prepareLiveKitConnection();
return true;
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -252,7 +287,7 @@ class VideoCallManager {
if (_snapshot.conversationUuid != conversationUuid ||
_room == null ||
_liveKitDisposed) {
await startCall(
return startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
@@ -271,6 +306,7 @@ class VideoCallManager {
unawaited(_ensureLocalParticipantNamePublished());
_syncRemoteParticipantNameFromRoom();
}
return true;
}
}
@@ -391,10 +427,19 @@ class VideoCallManager {
Future<void> endCall() async {
final uuid = _snapshot.conversationUuid;
final shouldNotifyBackend = shouldNotifyCallEnded(
alreadyNotified: _callEndedNotified,
hasConversation: uuid.isNotEmpty,
peerStillInRoom: _roomHasRemoteUser(),
);
if (uuid.isNotEmpty) {
_callEndedNotified = true;
_leftConversationAt[uuid] = DateTime.now();
}
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
if (!_callEndedNotified && uuid.isNotEmpty) {
_callEndedNotified = true;
if (shouldNotifyBackend) {
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
@@ -444,6 +489,12 @@ class VideoCallManager {
}
await endCall();
if (!incomingDeclineShouldNotifyBackend(
leftAt: _leftConversationAt[uuid],
now: DateTime.now(),
)) {
return;
}
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
@@ -477,6 +528,9 @@ class VideoCallManager {
next,
audioCaptureOptions: _micCaptureOptions,
);
if (next) {
await _audioSession.restartCapture();
}
} catch (_) {
return;
}
@@ -674,6 +728,7 @@ class VideoCallManager {
true,
audioCaptureOptions: _micCaptureOptions,
);
await _audioSession.restartCapture();
} catch (_) {}
}
await _resumeVideoAfterForeground();
@@ -814,6 +869,12 @@ class VideoCallManager {
}
}
void _requestForceEnd() {
if (_forceEndRequested || _liveKitDisposed) return;
_forceEndRequested = true;
_emitSnapshot(_snapshot.copyWith(forceEndCall: true));
}
void _attachRoomEventListeners() {
final room = _room;
if (room == null) return;
@@ -825,11 +886,34 @@ class VideoCallManager {
if (_snapshot.linkIssue.needsManualReconnect) return;
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
})
..on<lk.RoomDisconnectedEvent>((_) {
..on<lk.RoomDisconnectedEvent>((event) {
if (_liveKitDisposed) return;
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
if (shouldForceEndCallAfterRoomDisconnect(
callStageEntered: _callStageEntered,
sessionConnected: true,
reason: event.reason,
)) {
_requestForceEnd();
return;
}
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
})
..on<lk.ParticipantDisconnectedEvent>((_) {
final room = _room;
if (room == null) return;
if (!shouldForceEndCallAfterPeerLeft(
callStageEntered: _callStageEntered,
sessionConnected:
_snapshot.connectPhase == VideoCallConnectPhase.connected,
remoteIdentities: room.remoteParticipants.values.map(
(participant) => participant.identity,
),
)) {
return;
}
_requestForceEnd();
})
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != room.localParticipant) return;
_syncPoorConnectionOverlay(e.connectionQuality);
@@ -922,6 +1006,7 @@ 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(
@@ -970,6 +1055,7 @@ 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,
);
@@ -1048,6 +1134,22 @@ 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;
return videoCallRoomStillHasRemoteUser(
room.remoteParticipants.values.map((participant) => participant.identity),
);
}
bool get _canHandleCallAudio =>
_callStageEntered &&
!_liveKitDisposed &&
@@ -1089,6 +1191,7 @@ class VideoCallManager {
true,
audioCaptureOptions: _micCaptureOptions,
);
await _audioSession.restartCapture();
} catch (_) {
effectiveMic = false;
}

View File

@@ -11,6 +11,7 @@ class VideoCallManagerSnapshot {
required this.connectPhase,
required this.linkIssue,
this.counterpartPresentInLobby = false,
this.forceEndCall = false,
});
final String conversationUuid;
@@ -22,6 +23,7 @@ class VideoCallManagerSnapshot {
final VideoCallConnectPhase connectPhase;
final VideoCallLinkIssue linkIssue;
final bool counterpartPresentInLobby;
final bool forceEndCall;
static const initial = VideoCallManagerSnapshot(
conversationUuid: '',
@@ -41,6 +43,7 @@ class VideoCallManagerSnapshot {
VideoCallConnectPhase? connectPhase,
VideoCallLinkIssue? linkIssue,
bool? counterpartPresentInLobby,
bool? forceEndCall,
bool clearConnectError = false,
}) {
return VideoCallManagerSnapshot(
@@ -54,6 +57,7 @@ class VideoCallManagerSnapshot {
linkIssue: linkIssue ?? this.linkIssue,
counterpartPresentInLobby:
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
forceEndCall: forceEndCall ?? this.forceEndCall,
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:flutter/material.dart';
@@ -30,6 +31,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
final VideoCallSystemPipCoordinator _systemPipCoordinator;
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
bool _callScreenRouteVisible = false;
bool _handlingRemoteCallEnd = false;
bool _suppressDisposeSideEffects = false;
bool _autoEnterInFlight = false;
bool _counterpartPresentWhenLobbyReady = false;
bool _sawEmptyLobbyWhenConnected = false;
@@ -74,6 +77,10 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return;
if (snapshot.forceEndCall) {
unawaited(handleRemoteCallEnded());
return;
}
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) {
@@ -164,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
);
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -218,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return;
return true;
}
if (isResumingActiveCall) {
@@ -248,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,
videoEnabled: false,
);
if (!started) {
if (!isClosed) emit(const VideoCallSessionState());
return false;
}
emit(state.copyWith(entryMode: effectiveEntryMode));
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
@@ -266,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return true;
}
Future<void> _ensureSystemPipArmedIfInCall() async {
@@ -377,8 +389,32 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
}
/// The other participant hung up or was removed from the room.
Future<void> handleRemoteCallEnded() async {
if (_handlingRemoteCallEnd || isClosed) return;
if (_videoCallManager.callEndedNotified) return;
final inCall =
state.callStageEntered ||
state.connectPhase == VideoCallConnectPhase.connected;
if (!inCall) return;
_handlingRemoteCallEnd = true;
_suppressDisposeSideEffects = true;
try {
await endCall();
if (isClosed) return;
emit(const VideoCallSessionState());
await _restoreSystemUiAfterCall();
VideoCallNotificationNavigator.closeCallRouteIfOpen();
} finally {
_suppressDisposeSideEffects = false;
_handlingRemoteCallEnd = false;
}
}
void onCallScreenDisposed() {
_callScreenRouteVisible = false;
if (_suppressDisposeSideEffects) return;
if (state.uiMode != VideoCallUiMode.fullscreen) {
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
@@ -510,9 +546,20 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
Future<void> endCall() async {
await _screenWakeLock.release();
await _systemPipCoordinator.disarm();
await _videoCallManager.endCall();
_suppressDisposeSideEffects = true;
try {
await _screenWakeLock.release();
await _systemPipCoordinator.disarm();
await _videoCallManager.endCall();
if (isClosed) return;
// The idle snapshot is ignored while the call screen is still visible,
// so hang up has to clear the session before the route pops. Otherwise
// dispose keeps the call and the next token request rings both users.
emit(const VideoCallSessionState());
await _restoreSystemUiAfterCall();
} finally {
_suppressDisposeSideEffects = false;
}
}
void expandLocal() {

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

@@ -31,7 +31,7 @@ class VideoCallAudioSessionCoordinator {
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionMode: AVAudioSessionMode.videoChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
@@ -88,6 +88,15 @@ class VideoCallAudioSessionCoordinator {
}
}
/// Restarts capture after the microphone track has been created.
Future<void> restartCapture() async {
if (!_isIos) return;
try {
await _iosCallAudioSession('restart');
_iosActive = true;
} catch (_) {}
}
Future<void> stop() async {
if (_isIos) {
_iosActive = false;

View File

@@ -8,7 +8,9 @@ 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:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
@@ -362,7 +364,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleDecline(id);
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _handleEndedEvent(CallEvent event) async {
@@ -374,7 +376,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleEnded(id);
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
@@ -842,6 +844,7 @@ class VideoCallCallKitCoordinator {
bool? counterpartIsConsultant,
}) async {
FlutterNativeSplash.remove();
_manager.permitParticipantTokenRequest();
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
@@ -853,7 +856,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 +869,29 @@ 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 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,10 +903,7 @@ class VideoCallCallKitCoordinator {
}),
);
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
_forgetCallKitId(callKitId);
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
@@ -896,7 +917,10 @@ class VideoCallCallKitCoordinator {
}
}
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 +929,36 @@ 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,
);
if (remoteEnd) {
VideoCallRemoteEndRegistry.mark(callKitId);
}
_forgetCallKitId(callKitId);
// Native already ends a remote-ended call. Calling endCall again here
// overwrites the plugin's stored payload, so the decline that follows
// would lose the marker.
if (remoteEnd) return;
try {
await FlutterCallkitIncoming.endCall(callKitId);
} catch (_) {}
}
void _forgetCallKitId(String callKitId) {
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
}
static String _parseConversationUuid(
Map<String, Object?> extra, {
String? handle,

View File

@@ -1,6 +1,10 @@
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_remote_end_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';
/// Dismisses incoming call UI after a remote `video_call_ended` push.
@@ -9,7 +13,8 @@ abstract final class VideoCallEndedPushHandler {
'com.eatvioapp/video_call_push',
);
static Future<void> dismissIncomingCallUi() async {
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
await endActiveCallIfMatches(conversationUuid);
if (Platform.isAndroid) {
try {
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
@@ -17,21 +22,59 @@ abstract final class VideoCallEndedPushHandler {
} catch (_) {}
}
await _dismissViaCallKit();
await _dismissViaCallKit(conversationUuid);
}
static Future<void> _dismissViaCallKit() async {
static Future<void> endActiveCallIfMatches(String? conversationUuid) 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;
try {
await cubit.handleRemoteCallEnded();
} catch (_) {}
}
static bool callMatchesConversation(
CallKitParams call,
String conversationUuid,
) {
final uuid = conversationUuid.trim().toLowerCase();
if (uuid.isEmpty) return false;
final handle = call.handle?.trim().toLowerCase();
if (handle != null && handle == uuid) return true;
final extra = call.extra;
if (extra == null) return false;
for (final key in const ['conversationUuid', 'conversation_uuid']) {
final value = extra[key];
if (value is String && value.trim().toLowerCase() == uuid) return true;
}
return false;
}
/// The close of a ringing call comes back from the plugin as a decline.
/// Each id is marked first, so the coordinator does not report that
/// decline to the backend as if the user had rejected the call.
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
final uuid = conversationUuid?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
for (final call in activeCalls) {
VideoCallRemoteEndRegistry.mark(call.id);
}
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
try {
await FlutterCallkitIncoming.endAllCalls();
} catch (_) {}
}
}

View File

@@ -1,11 +1,23 @@
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
/// Marker for a data-only `video_call_ended` push (no conversation uuid required).
/// Marker for a data-only `video_call_ended` push.
class VideoCallEndedPushPayload {
const VideoCallEndedPushPayload._();
const VideoCallEndedPushPayload({this.conversationUuid});
final String? conversationUuid;
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
return const VideoCallEndedPushPayload._();
final rawUuid =
data['conversationUuid'] ??
data['conversation_uuid'] ??
data['uuid'];
final conversationUuid = rawUuid?.toString().trim();
return VideoCallEndedPushPayload(
conversationUuid:
conversationUuid == null || conversationUuid.isEmpty
? null
: conversationUuid,
);
}
}

View File

@@ -62,7 +62,9 @@ abstract final class VideoCallFcmHandler {
name: 'VideoCallFcm',
);
try {
await VideoCallEndedPushHandler.dismissIncomingCallUi();
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
);
} catch (e, st) {
log(
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',

View File

@@ -2,18 +2,62 @@ 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:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
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.
///
/// On iOS the VoIP push reports the call even when the app is in the
/// foreground. A second report for the same id dismisses that UI, so the
/// socket path waits for the push and presents CallKit only if the push
/// never arrived.
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
/// 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>[
Duration.zero,
Duration(milliseconds: 300),
Duration(milliseconds: 800),
Duration(milliseconds: 1500),
];
static Future<void> showIncomingCall(
VideoCallPushPayload payload, {
bool? isIos,
VideoCallIncomingClaim? isIncomingCallClaimed,
VideoCallIncomingClaim? reserveIncomingCall,
VideoCallIncomingShow? showCallkitIncoming,
VideoCallIncomingDelay? delay,
List<Duration>? claimPollDelays,
VideoCallAndroidIncomingShow? showAndroidIncoming,
}) async {
final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final callId = Platform.isIOS
final ios = isIos ?? Platform.isIOS;
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
return;
}
final callId = ios
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
@@ -21,6 +65,22 @@ abstract final class VideoCallIncomingPushPresenter {
payload.conversationUuid
: payload.conversationUuid;
if (ios) {
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
final reserve = reserveIncomingCall ?? _reserveIncomingCall;
final wait = delay ?? Future<void>.delayed;
final polls = claimPollDelays ?? iosClaimPollDelays;
final started = DateTime.now();
for (final checkpoint in polls) {
final remaining = checkpoint - DateTime.now().difference(started);
if (remaining > Duration.zero) {
await wait(remaining);
}
if (await _claimOrFalse(claimed, callId)) return;
}
if (!await _claimOrFalse(reserve, callId)) return;
}
final params = CallKitParams(
id: callId,
nameCaller: callerName,
@@ -41,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
'counterpartIsConsultant': payload.counterpartIsConsultant,
},
callingNotification: const NotificationParams(showNotification: false),
missedCallNotification: const NotificationParams(
showNotification: true,
subtitle: iosMissedCallSubtitle,
callbackText: iosMissedCallCallbackText,
),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
@@ -57,10 +122,62 @@ abstract final class VideoCallIncomingPushPresenter {
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
ringtonePath: 'system_ringtone_default',
configureAudioSession: false,
audioSessionActive: false,
audioSessionMode: 'videoChat',
),
);
await FlutterCallkitIncoming.showCallkitIncoming(params);
await (showCallkitIncoming ?? FlutterCallkitIncoming.showCallkitIncoming)(
params,
);
}
static Future<void> _showAndroidIncoming(VideoCallPushPayload payload) async {
try {
await _androidPushChannel
.invokeMethod<void>('showIncomingCall', <String, String>{
'conversation_uuid': payload.conversationUuid,
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(),
});
} catch (_) {}
}
static Future<bool> _claimOrFalse(
VideoCallIncomingClaim claim,
String callId,
) async {
try {
return await claim(callId);
} catch (_) {
return false;
}
}
static Future<bool> _isIncomingCallClaimed(String callId) async {
final claimed = await _voipChannel.invokeMethod<bool>(
'isIncomingCallClaimed',
<String, String>{'id': callId},
);
return claimed ?? false;
}
static Future<bool> _reserveIncomingCall(String callId) async {
final reserved = await _voipChannel.invokeMethod<bool>(
'reserveIncomingCall',
<String, String>{'id': callId},
);
return reserved ?? false;
}
}

View File

@@ -74,6 +74,17 @@ class VideoCallNotificationNavigator {
return isOnCallRoute();
}
static void closeCallRouteIfOpen() {
try {
if (!isOnCallRoute()) return;
final context = AppRouter.navigatorKey.currentContext;
if (context == null || !context.mounted) return;
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
} catch (_) {}
}
static bool isOnCallRoute() {
final matches =
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;

View File

@@ -0,0 +1,93 @@
import 'package:livekit_client/livekit_client.dart' as lk;
/// Real call participants use the `user-{id}` identity. Egress and other
/// bots must not keep the call alive after the other person leaves.
bool videoCallRoomStillHasRemoteUser(Iterable<String> remoteIdentities) {
for (final identity in remoteIdentities) {
if (identity.startsWith('user-')) return true;
}
return false;
}
bool shouldForceEndCallAfterPeerLeft({
required bool callStageEntered,
required bool sessionConnected,
required Iterable<String> remoteIdentities,
}) {
if (!callStageEntered || !sessionConnected) return false;
return !videoCallRoomStillHasRemoteUser(remoteIdentities);
}
/// The participant who taps hang up must not report the end while the other
/// person is still in the room. That report and the remaining client's report
/// both see an empty room and each one rings the other user again.
bool shouldNotifyCallEnded({
required bool alreadyNotified,
required bool hasConversation,
required bool peerStillInRoom,
}) {
if (!hasConversation || alreadyNotified || peerStillInRoom) return false;
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,
required lk.DisconnectReason? reason,
}) {
if (!callStageEntered || !sessionConnected) return false;
return reason == lk.DisconnectReason.participantRemoved ||
reason == lk.DisconnectReason.roomDeleted;
}

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

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.50+139
version: 1.3.58+147
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

@@ -1,19 +1,27 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cached_base_url.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MockVideoCallSessionCubit extends Mock implements VideoCallSessionCubit {}
class _MockVideoCallSessionCubit extends Mock
implements VideoCallSessionCubit {}
void main() {
setUpAll(() async {
// Initialize dotenv for tests
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
dotenv.loadFromString(
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
);
});
setUp(() async {
@@ -90,6 +98,75 @@ void main() {
expect(devConfig.baseUrl, testConfig.baseUrl);
});
test('setBaseUrl saves override to prefs', () async {
SharedPreferences.setMockInitialValues({});
final prefs = Prefs(await SharedPreferences.getInstance());
sl.registerSingleton<Prefs>(prefs);
final config = AppConfig(buildType: AppBuildType.prod);
config.setBaseUrl('https://custom.example');
await Future<void>.delayed(Duration.zero);
expect(
prefs.getString(PrefsKeys.customBaseUrl),
'https://custom.example',
);
});
test('setBaseUrl removes cached override when url is blank', () async {
SharedPreferences.setMockInitialValues({
'customBaseUrl': 'https://custom.example',
});
final prefs = Prefs(await SharedPreferences.getInstance());
sl.registerSingleton<Prefs>(prefs);
final config = AppConfig(buildType: AppBuildType.prod);
config.setBaseUrl(' ');
await Future<void>.delayed(Duration.zero);
expect(prefs.getString(PrefsKeys.customBaseUrl), isNull);
expect(config.baseUrl, ' ');
});
test(
'applyCachedBaseUrl replaces env base url before dio is created',
() async {
SharedPreferences.setMockInitialValues({
'customBaseUrl': 'https://cached.example',
});
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.prod),
);
sl.registerSingleton<Prefs>(
Prefs(await SharedPreferences.getInstance()),
);
sl.registerLazySingleton<Dio>(
() => Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl)),
);
applyCachedBaseUrl();
expect(sl<AppConfig>().baseUrl, 'https://cached.example');
expect(sl<Dio>().options.baseUrl, 'https://cached.example');
},
);
test('applyCachedBaseUrl keeps env base url when cache is empty', () async {
SharedPreferences.setMockInitialValues({});
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.prod),
);
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
sl.registerLazySingleton<Dio>(
() => Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl)),
);
applyCachedBaseUrl();
expect(sl<AppConfig>().baseUrl, EnvConfig.baseUrl);
expect(sl<Dio>().options.baseUrl, EnvConfig.baseUrl);
});
test('setBaseUrl can be called multiple times', () {
final config = AppConfig(buildType: AppBuildType.prod);
const url1 = 'https://url1.com';

View File

@@ -4,57 +4,46 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
group('PrefsKeys', () {
test('has all expected values', () {
expect(PrefsKeys.values.length, 5);
expect(PrefsKeys.values.length, 6);
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
expect(
PrefsKeys.values,
contains(PrefsKeys.marketingBottomSheetHasBeenShown),
);
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
});
test('can be compared', () {
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
expect(
PrefsKeys.hasAnonymousTokenBeenSent ==
PrefsKeys.hasAnonymousTokenBeenSent,
true,
);
expect(
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
false,
);
});
test('has correct name property', () {
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
expect(
PrefsKeys.hasAnonymousTokenBeenSent.name,
'hasAnonymousTokenBeenSent',
);
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
expect(PrefsKeys.registrationDate.name, 'registrationDate');
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
expect(
PrefsKeys.marketingBottomSheetHasBeenShown.name,
'marketingBottomSheetHasBeenShown',
);
expect(
PrefsKeys.isEmailVerificationSkipped.name,
'isEmailVerificationSkipped',
);
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
});
});
}

View File

@@ -4,25 +4,46 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
group('PrefsKeys', () {
test('все значения enum определены', () {
expect(PrefsKeys.values.length, 5);
expect(PrefsKeys.values.length, 6);
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
expect(
PrefsKeys.values,
contains(PrefsKeys.marketingBottomSheetHasBeenShown),
);
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
});
test('имена ключей корректны', () {
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
expect(
PrefsKeys.hasAnonymousTokenBeenSent.name,
'hasAnonymousTokenBeenSent',
);
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
expect(PrefsKeys.registrationDate.name, 'registrationDate');
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
expect(
PrefsKeys.marketingBottomSheetHasBeenShown.name,
'marketingBottomSheetHasBeenShown',
);
expect(
PrefsKeys.isEmailVerificationSkipped.name,
'isEmailVerificationSkipped',
);
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
});
test('can be compared', () {
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
expect(
PrefsKeys.hasAnonymousTokenBeenSent ==
PrefsKeys.hasAnonymousTokenBeenSent,
true,
);
expect(
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
false,
);
});
test('has correct index', () {
@@ -33,43 +54,22 @@ void main() {
});
test('can access by name', () {
expect(PrefsKeys.values.byName('hasAnonymousTokenBeenSent'), PrefsKeys.hasAnonymousTokenBeenSent);
expect(PrefsKeys.values.byName('welcomeScreenWasShowed'), PrefsKeys.welcomeScreenWasShowed);
expect(PrefsKeys.values.byName('registrationDate'), PrefsKeys.registrationDate);
expect(PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'), PrefsKeys.marketingBottomSheetHasBeenShown);
expect(
PrefsKeys.values.byName('hasAnonymousTokenBeenSent'),
PrefsKeys.hasAnonymousTokenBeenSent,
);
expect(
PrefsKeys.values.byName('welcomeScreenWasShowed'),
PrefsKeys.welcomeScreenWasShowed,
);
expect(
PrefsKeys.values.byName('registrationDate'),
PrefsKeys.registrationDate,
);
expect(
PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'),
PrefsKeys.marketingBottomSheetHasBeenShown,
);
});
});
}

View File

@@ -479,6 +479,39 @@ void main() {
},
);
test(
'_handleNewMessageViaSocket - завершение видеозвонка закрывает только этот чат',
() async {
const chatId = 'chat-id';
final dismissed = <String?>[];
chatsCubit.dismissIncomingVideoCall = (uuid) async {
dismissed.add(uuid);
};
chatsCubit.emit(
ChatsState(
state: BaseState.success,
chatRooms: [buildChatRoom(chatId)],
),
);
when(mockWsManager.userId).thenReturn(42);
final controller = await connectToSocketStream();
controller.add(
socketEvent(
id: 502,
authorId: 7,
roomId: chatId,
data: 'Анна завершил(а) видеозвонок.',
),
);
await Future.delayed(const Duration(milliseconds: 100));
expect(dismissed, [chatId]);
await controller.close();
},
);
test('close - закрывает cubit и отписывается от подписок', () async {
final mockSubscription1 = MockStreamSubscription();

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,7 +1,10 @@
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';
import 'dart:collection';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
@@ -53,6 +56,9 @@ void main() {
mockRoom = MockRoom();
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
when(mockRoom.localParticipant).thenReturn(null);
when(
mockRoom.remoteParticipants,
).thenReturn(UnmodifiableMapView(<String, lk.RemoteParticipant>{}));
});
tearDown(() {
@@ -77,6 +83,47 @@ 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));
},
);
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',
() async {
when(
mockRepository.notifyCallEnded(any),
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
await manager.declineIncomingCall('other-uuid');
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
},
);
test('endCall stops foreground service', () async {
manager.markActiveCallForTesting(mockRoom);

View File

@@ -124,6 +124,36 @@ void main() {
},
);
test('force end from the other participant closes the call', () async {
when(mockManager.callEndedNotified).thenReturn(false);
when(mockManager.endCall()).thenAnswer((_) async {});
cubit.emit(
const VideoCallSessionState(
conversationUuid: 'uuid',
connectPhase: VideoCallConnectPhase.connected,
callStageEntered: true,
uiMode: VideoCallUiMode.fullscreen,
),
);
managerListener(
const VideoCallManagerSnapshot(
conversationUuid: 'uuid',
micEnabled: true,
videoEnabled: true,
connectPhase: VideoCallConnectPhase.connected,
linkIssue: VideoCallLinkIssue.none,
forceEndCall: true,
),
);
await Future<void>.delayed(Duration.zero);
verify(mockManager.endCall()).called(1);
expect(cubit.state.connectPhase, VideoCallConnectPhase.idle);
expect(cubit.state.callStageEntered, isFalse);
expect(cubit.state.uiMode, VideoCallUiMode.hidden);
});
test('isMicActiveForUi mirrors micEnabled', () {
const state = VideoCallSessionState(micEnabled: true);
expect(state.isMicActiveForUi, isTrue);
@@ -145,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',
@@ -169,7 +211,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
await cubit.onCallScreenVisible(
conversationUuid: 'uuid',
@@ -201,6 +243,7 @@ void main() {
linkIssue: VideoCallLinkIssue.none,
),
);
return true;
});
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
@@ -232,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);
@@ -267,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);
@@ -298,7 +341,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
await cubit.onCallScreenVisible(
conversationUuid: 'uuid',
@@ -332,7 +375,7 @@ void main() {
micEnabled: anyNamed('micEnabled'),
videoEnabled: anyNamed('videoEnabled'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => true);
cubit.emit(
const VideoCallSessionState(
@@ -360,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

@@ -20,9 +20,12 @@ void main() {
final resumed = await coordinator.reactivate();
expect(resumed, isTrue);
await coordinator.restartCapture();
expect(coordinator.isActive, isTrue);
await coordinator.stop();
expect(coordinator.isActive, isFalse);
expect(calls, ['activate', 'activate', 'deactivate']);
expect(calls, ['activate', 'activate', 'restart', 'deactivate']);
});
test('stop clears the active session when deactivate fails', () async {

View File

@@ -0,0 +1,107 @@
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', () {
const call = CallKitParams(id: 'derived-id', handle: conversation);
expect(
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
isTrue,
);
});
test('matches the conversation stored in the call payload', () {
const call = CallKitParams(
id: 'derived-id',
handle: 'Итвио',
extra: <String, dynamic>{'conversationUuid': conversation},
);
expect(
VideoCallEndedPushHandler.callMatchesConversation(
call,
conversation.toUpperCase(),
),
isTrue,
);
});
test('leaves a call from another chat alone', () {
const call = CallKitParams(id: 'other-id', handle: otherConversation);
expect(
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
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(
'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);
},
);
});
}

View File

@@ -0,0 +1,178 @@
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
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:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const payload = VideoCallPushPayload(
conversationUuid: '11111111-1111-4111-8111-111111111111',
counterpartName: ' Anna ',
messageId: 26,
);
final callId = VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
);
group('VideoCallIncomingPushPresenter', () {
test(
'iOS keeps the push-reported call and does not show it again',
() async {
final shown = <CallKitParams>[];
final delays = <Duration>[];
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
isIncomingCallClaimed: (_) async => true,
reserveIncomingCall: (_) async => true,
showCallkitIncoming: (params) async {
shown.add(params);
},
delay: (duration) async {
delays.add(duration);
},
);
expect(shown, isEmpty);
expect(delays, isEmpty);
expect(callId, isNotNull);
},
);
test('iOS shows the system call once the push does not claim it', () async {
final shown = <CallKitParams>[];
var polls = 0;
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[
Duration.zero,
Duration(milliseconds: 300),
],
isIncomingCallClaimed: (_) async {
polls += 1;
return false;
},
reserveIncomingCall: (id) async {
expect(id, callId);
return true;
},
showCallkitIncoming: (params) async {
shown.add(params);
},
delay: (_) async {},
);
expect(polls, 2);
expect(shown, hasLength(1));
expect(shown.single.id, callId);
expect(shown.single.nameCaller, 'Anna');
expect(shown.single.ios?.configureAudioSession, isFalse);
expect(shown.single.ios?.audioSessionActive, isFalse);
expect(shown.single.ios?.audioSessionMode, 'videoChat');
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 {
final shown = <CallKitParams>[];
var polls = 0;
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[
Duration.zero,
Duration(milliseconds: 300),
Duration(milliseconds: 800),
],
isIncomingCallClaimed: (_) async {
polls += 1;
return polls == 2;
},
reserveIncomingCall: (_) async => true,
showCallkitIncoming: (params) async {
shown.add(params);
},
delay: (_) async {},
);
expect(polls, 2);
expect(shown, isEmpty);
});
test('iOS does not show when the call was reserved by the push', () async {
final shown = <CallKitParams>[];
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[Duration.zero],
isIncomingCallClaimed: (_) async => false,
reserveIncomingCall: (_) async => false,
showCallkitIncoming: (params) async {
shown.add(params);
},
delay: (_) async {},
);
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 {
final shown = <CallKitParams>[];
var claimed = false;
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: false,
isIncomingCallClaimed: (_) async {
claimed = true;
return true;
},
showCallkitIncoming: (params) async {
shown.add(params);
},
);
expect(claimed, isFalse);
expect(shown, hasLength(1));
expect(shown.single.id, payload.conversationUuid);
expect(shown.single.android?.ringtonePath, 'system_ringtone_default');
},
);
});
}

View File

@@ -0,0 +1,195 @@
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
void main() {
test('ends the call when the other user leaves the room', () {
expect(
shouldForceEndCallAfterPeerLeft(
callStageEntered: true,
sessionConnected: true,
remoteIdentities: const [],
),
isTrue,
);
});
test('keeps the call when another user is still in the room', () {
expect(
shouldForceEndCallAfterPeerLeft(
callStageEntered: true,
sessionConnected: true,
remoteIdentities: const ['user-7', 'EG_recorder'],
),
isFalse,
);
});
test('ignores an egress bot after the other user has left', () {
expect(
shouldForceEndCallAfterPeerLeft(
callStageEntered: true,
sessionConnected: true,
remoteIdentities: const ['EG_recorder'],
),
isTrue,
);
});
test('does not end a call that has not entered the stage', () {
expect(
shouldForceEndCallAfterPeerLeft(
callStageEntered: false,
sessionConnected: true,
remoteIdentities: const [],
),
isFalse,
);
});
test('ends the call when the server removes this participant', () {
expect(
shouldForceEndCallAfterRoomDisconnect(
callStageEntered: true,
sessionConnected: true,
reason: lk.DisconnectReason.participantRemoved,
),
isTrue,
);
expect(
shouldForceEndCallAfterRoomDisconnect(
callStageEntered: true,
sessionConnected: true,
reason: lk.DisconnectReason.roomDeleted,
),
isTrue,
);
});
test('does not report the end while the other user is still in the room', () {
expect(
shouldNotifyCallEnded(
alreadyNotified: false,
hasConversation: true,
peerStillInRoom: true,
),
isFalse,
);
});
test('reports the end when this client is the last participant', () {
expect(
shouldNotifyCallEnded(
alreadyNotified: false,
hasConversation: true,
peerStillInRoom: false,
),
isTrue,
);
expect(
shouldNotifyCallEnded(
alreadyNotified: true,
hasConversation: true,
peerStillInRoom: false,
),
isFalse,
);
});
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(
callStageEntered: true,
sessionConnected: true,
reason: lk.DisconnectReason.disconnected,
),
isFalse,
);
});
}

View File

@@ -57,6 +57,7 @@ void main() {
});
expect(payload, isNotNull);
expect(payload!.conversationUuid, 'abc-123');
});
test('parses message id for a stable CallKit uuid', () {

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