forked from eatvio/eatvio_app
Compare commits
55 Commits
issue-1062
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e6526552 | |||
|
|
1c8709fc50 | ||
| 6b4c88068e | |||
|
|
d30c63be85 | ||
|
|
0ddd4c6b14 | ||
| c45612decb | |||
|
|
2779f79464 | ||
| 05eaa3caa9 | |||
|
|
d8d7a211bd | ||
| e6601e2fe6 | |||
|
|
992573aead | ||
| d2fc087699 | |||
|
|
3122394037 | ||
| 18925fd95e | |||
|
|
9916828956 | ||
| fb68f02ca4 | |||
|
|
8cb3a184b0 | ||
| 4b72258b1f | |||
|
|
592f387221 | ||
|
|
dcf695777a | ||
|
|
141cf5543d | ||
| 40ba5f304e | |||
|
|
996cf07fb8 | ||
|
|
1766314bbe | ||
| d1575d7ac4 | |||
| 94bca6a793 | |||
|
|
a6b94f835c | ||
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
|
|
6d2935400a | ||
| 293601f1e1 | |||
|
|
ff17b813ac | ||
| 6a259e5452 | |||
|
|
9288a5c849 | ||
|
|
4ef174ebde | ||
|
|
1901248539 | ||
| b2e700a4eb | |||
| 61f1b76f99 | |||
| 572a6f1943 | |||
|
|
a6ae9abb57 | ||
| 431354170e | |||
|
|
c38e82f558 | ||
| afd6f9a93a | |||
|
|
641eb4d2e2 | ||
| 74c66c9600 | |||
|
|
73e89ff7a9 |
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal 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
|
||||||
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal 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"
|
||||||
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal 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"]
|
||||||
@@ -39,7 +39,7 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
YANDEX_CLIENT_ID: yandexClientId,
|
YANDEX_CLIENT_ID: yandexClientId,
|
||||||
applicationName: "io.flutter.app.FlutterApplication"
|
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
implementation 'com.google.firebase:firebase-messaging'
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||||
|
|||||||
@@ -87,8 +87,11 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
<!-- The incoming notification's content intent and full-screen intent both open
|
||||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
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
|
<activity
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
@@ -97,20 +100,26 @@
|
|||||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation" />
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation"
|
android:configChanges="orientation"
|
||||||
android:permission="${applicationId}.PERMISSION_CALL">
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return payloadMap(data, action)
|
||||||
"action" to action,
|
}
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
|
||||||
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||||
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
|
||||||
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
return payloadMap(data, action)
|
||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
||||||
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
intent.action = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to action,
|
"action" to action,
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||||
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
|
|||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCallKitLaunchIntent(intent: Intent?) {
|
|
||||||
if (intent == null) return
|
|
||||||
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
|
||||||
intent.action = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
|||||||
|
|
||||||
object CallKitIntentBridge {
|
object CallKitIntentBridge {
|
||||||
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||||
|
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||||
|
VideoCallPushBridge.silenceIncomingCall(context)
|
||||||
|
}
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||||
PendingCallKitAcceptBridge.store(payload)
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
}
|
}
|
||||||
|
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
|
||||||
|
PendingCallKitDeclineBridge.store(payload)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
MainActivity.onPendingDecline?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class EatvioApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
VideoCallDeclineReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,71 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
import android.app.Activity
|
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.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.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
|
/// Target of the incoming notification's content intent and full-screen intent.
|
||||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
///
|
||||||
|
/// 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() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
callData = data
|
||||||
|
|
||||||
CallKitIntentBridge.dispatchCallKitAction(
|
if (!launchedOnLockedOrDarkScreen()) {
|
||||||
this,
|
Log.i(TAG, "tap on notification body: accept")
|
||||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
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()
|
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||||
@Suppress("DEPRECATION")
|
if (!avatarUrl.isNullOrEmpty()) {
|
||||||
overridePendingTransition(0, 0)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
Log.i(TAG, "onMessageReceived: ${message.data}")
|
Log.i(TAG, "onMessageReceived: ${message.data}")
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
if (message == null) return
|
if (message == null) return
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
var isInForeground: Boolean = false
|
var isInForeground: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
var onPendingDecline: (() -> Unit)? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
@@ -38,13 +41,18 @@ class MainActivity: FlutterActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
captureCallKitLaunchIntent(intent)
|
captureCallKitLaunchIntent(intent)
|
||||||
if (PendingCallKitAcceptBridge.peek() != null) {
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
scheduleDeliverPendingCallKitAccept()
|
scheduleDeliverPendingCallKitAccept()
|
||||||
}
|
}
|
||||||
|
if (PendingCallKitDeclineBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitDecline()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
onPendingDecline = null
|
||||||
isInForeground = false
|
isInForeground = false
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
@@ -55,22 +63,50 @@ class MainActivity: FlutterActivity() {
|
|||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"dismissIncomingCalls" -> {
|
"dismissIncomingCalls" -> {
|
||||||
try {
|
try {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
val args = call.arguments as? Map<*, *>
|
||||||
|
val callId = args?.get("call_id")?.toString()
|
||||||
|
?: args?.get("callId")?.toString()
|
||||||
|
val conversationUuid = args?.get("conversation_uuid")?.toString()
|
||||||
|
?: args?.get("conversationUuid")?.toString()
|
||||||
|
VideoCallPushBridge.dismissIncomingCalls(
|
||||||
|
applicationContext,
|
||||||
|
callId = callId,
|
||||||
|
conversationUuid = conversationUuid,
|
||||||
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
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" -> {
|
"drainPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.drain())
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
}
|
}
|
||||||
"peekPendingCallKitAccept" -> {
|
"peekPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.peekPending())
|
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||||
}
|
}
|
||||||
|
"drainPendingCallKitDecline" -> {
|
||||||
|
result.success(PendingCallKitDeclineBridge.drain())
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +262,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitDecline() {
|
||||||
|
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitDeclineToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitDeclineToFlutter() {
|
||||||
|
val payload = PendingCallKitDeclineBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitDecline", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||||
|
///
|
||||||
|
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||||
|
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||||
|
/// user's other devices.
|
||||||
|
object PendingCallKitDeclineBridge {
|
||||||
|
private const val TAG = "PendingCallKitDecline"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pendingPayload: Map<String, Any?>? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
fun store(payload: Map<String, Any?>) {
|
||||||
|
pendingPayload = payload
|
||||||
|
Log.i(TAG, "stored pending decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peek(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun drain(): Map<String, Any?>? {
|
||||||
|
val payload = pendingPayload
|
||||||
|
pendingPayload = null
|
||||||
|
if (payload != null) {
|
||||||
|
Log.i(TAG, "drained pending decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleDeliverToFlutter(
|
||||||
|
deliver: () -> Unit,
|
||||||
|
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||||
|
) {
|
||||||
|
for (delayMs in delaysMs) {
|
||||||
|
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
internal object FlutterSharedPrefs {
|
||||||
|
const val NAME = "FlutterSharedPreferences"
|
||||||
|
private const val PREFIX = "flutter."
|
||||||
|
|
||||||
|
fun string(context: Context, key: String): String? {
|
||||||
|
return context
|
||||||
|
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(PREFIX + key, null)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object VideoCallDeclineClient {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val TIMEOUT_MS = 4_000
|
||||||
|
|
||||||
|
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
|
||||||
|
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
|
||||||
|
?: FlutterSharedPrefs.string(context, "customBaseUrl")
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = FlutterSharedPrefs.string(context, "token"),
|
||||||
|
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
|
||||||
|
fields = fields,
|
||||||
|
)
|
||||||
|
if (request == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val code = post(request)
|
||||||
|
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(request: VideoCallDeclineRequest): Int {
|
||||||
|
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = TIMEOUT_MS
|
||||||
|
readTimeout = TIMEOUT_MS
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Authorization", "Bearer ${request.token}")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.outputStream.use { stream ->
|
||||||
|
stream.write(request.body.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val code = connection.responseCode
|
||||||
|
val response = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
response?.use { stream -> stream.readBytes() }
|
||||||
|
return code
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends `POST /video-call/decline` from the process that handled the tap.
|
||||||
|
*
|
||||||
|
* The notification Decline button is an explicit broadcast into the CallKit
|
||||||
|
* plugin. That broadcast starts the process when the app was killed and does
|
||||||
|
* not start Flutter. The plugin notifies this callback before it returns, so
|
||||||
|
* the request can finish while the process is still alive.
|
||||||
|
*/
|
||||||
|
object VideoCallDeclineReporter : CallkitEventCallback {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val WAIT_FOR_HTTP_MS = 9_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
appContext = context.applicationContext
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(this)
|
||||||
|
Log.i(TAG, "installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
if (event != CallkitEventCallback.CallEvent.DECLINE) return
|
||||||
|
val context = appContext ?: return
|
||||||
|
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
|
||||||
|
val extra = copyExtra(callData)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Decline button starts this process and then the broadcast ends.
|
||||||
|
// Wait here so Android does not kill the process before the POST returns.
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyExtra(callData: Bundle): Map<String, Any?> {
|
||||||
|
val raw = BundleCompat.getSerializable(
|
||||||
|
callData,
|
||||||
|
CallkitConstants.EXTRA_CALLKIT_EXTRA,
|
||||||
|
HashMap::class.java,
|
||||||
|
) ?: return emptyMap()
|
||||||
|
return buildMap {
|
||||||
|
for ((key, value) in raw) {
|
||||||
|
if (key is String) put(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
|
||||||
|
*
|
||||||
|
* [callId] is omitted when it is missing, not a uuid, or equal to the
|
||||||
|
* conversation uuid: the server treats `call_id` as the session uuid.
|
||||||
|
*/
|
||||||
|
internal data class VideoCallDeclineRequest(
|
||||||
|
val url: String,
|
||||||
|
val token: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class VideoCallDeclineFields(
|
||||||
|
val conversationUuid: String,
|
||||||
|
val callId: String?,
|
||||||
|
val endedPush: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object VideoCallDeclineRequestFactory {
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
private val uuidPattern =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
|
||||||
|
val conversation = firstString(
|
||||||
|
extra,
|
||||||
|
listOf("conversationUuid", "conversation_uuid"),
|
||||||
|
) ?: handle?.trim().orEmpty()
|
||||||
|
val rawCallId = firstString(extra, listOf("call_id", "callId"))
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid = conversation,
|
||||||
|
callId = backendCallId(rawCallId, conversation),
|
||||||
|
endedPush = marksEndedPush(extra),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields,
|
||||||
|
): VideoCallDeclineRequest? {
|
||||||
|
if (fields.endedPush) return null
|
||||||
|
val conversation = fields.conversationUuid.trim()
|
||||||
|
val authToken = token?.trim().orEmpty()
|
||||||
|
val deviceId = hwId?.trim().orEmpty()
|
||||||
|
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
|
||||||
|
if (conversation.isEmpty() || authToken.isEmpty()) return null
|
||||||
|
if (deviceId.length < 8 || deviceId.length > 64) return null
|
||||||
|
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
|
||||||
|
|
||||||
|
val encoded = encodePathSegment(conversation)
|
||||||
|
return VideoCallDeclineRequest(
|
||||||
|
url = "$root/api/v2/chat/$encoded/video-call/decline",
|
||||||
|
token = authToken,
|
||||||
|
body = jsonBody(deviceId, fields.callId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backendCallId(callId: String?, conversationUuid: String): String? {
|
||||||
|
val id = callId?.trim().orEmpty()
|
||||||
|
if (!uuidPattern.matches(id)) return null
|
||||||
|
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
|
||||||
|
if (extra == null) return null
|
||||||
|
for (key in keys) {
|
||||||
|
val value = extra[key] as? String ?: continue
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isNotEmpty()) return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
|
||||||
|
val value = extra?.get("endedPush") ?: return false
|
||||||
|
return when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is Number -> value.toInt() != 0
|
||||||
|
is String -> {
|
||||||
|
val normalized = value.trim().lowercase()
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonBody(hwId: String, callId: String?): String {
|
||||||
|
val hw = jsonString(hwId)
|
||||||
|
if (callId == null) return """{"hw_id":$hw}"""
|
||||||
|
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePathSegment(value: String): String {
|
||||||
|
val bytes = value.encodeToByteArray()
|
||||||
|
return buildString(bytes.size) {
|
||||||
|
for (byte in bytes) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val allowed = code in 'A'.code..'Z'.code ||
|
||||||
|
code in 'a'.code..'z'.code ||
|
||||||
|
code in '0'.code..'9'.code ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
if (allowed) {
|
||||||
|
append(code.toChar())
|
||||||
|
} else {
|
||||||
|
append('%')
|
||||||
|
append(HEX[code ushr 4])
|
||||||
|
append(HEX[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(value: String): String {
|
||||||
|
val escaped = buildString {
|
||||||
|
for (char in value) {
|
||||||
|
when (char) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import android.content.Context
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
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.CallkitNotificationManager
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||||
@@ -16,15 +18,42 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
|||||||
import com.hiennv.flutter_callkit_incoming.addCall
|
import com.hiennv.flutter_callkit_incoming.addCall
|
||||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||||
|
import com.hiennv.flutter_callkit_incoming.removeCall
|
||||||
import java.util.HashMap
|
import java.util.HashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
object VideoCallPushBridge {
|
object VideoCallPushBridge {
|
||||||
private const val TAG = "VideoCallPushBridge"
|
private const val TAG = "VideoCallPushBridge"
|
||||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||||
|
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val playbackLock = Any()
|
||||||
|
private val playbackGeneration = AtomicInteger(0)
|
||||||
|
private val tombstonedCallIds = HashMap<String, Long>()
|
||||||
|
|
||||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||||
private var activeNotificationManager: CallkitNotificationManager? = 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 {
|
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
||||||
val rawType = data["type"]
|
val rawType = data["type"]
|
||||||
@@ -89,18 +118,191 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
storedAppContext = appContext
|
||||||
|
ensureIncomingCallCallbackRegistered()
|
||||||
|
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
if (isTombstoned(serverCallId)) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val callData = buildCallDataBundle(data)
|
val callData = buildCallDataBundle(data)
|
||||||
if (callData == null) {
|
if (callData == null) {
|
||||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||||
return
|
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: id=$callId conversation=${conversationUuid(data)}")
|
||||||
|
|
||||||
|
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||||
|
// pushes on a background thread, while Answer runs on the main thread.
|
||||||
|
// A second show for the same conversation must not bump the generation:
|
||||||
|
// that would cancel the timeout of the call that is already ringing.
|
||||||
|
mainHandler.post {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
if (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 dismissIncomingCalls(
|
||||||
|
context: Context,
|
||||||
|
callId: String? = null,
|
||||||
|
conversationUuid: String? = null,
|
||||||
|
) {
|
||||||
|
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
if (normalizedCallId != null) {
|
||||||
|
markTombstone(normalizedCallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val calls = getDataActiveCalls(appContext)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||||
|
"conversation=${normalizedConversation ?: "-"}",
|
||||||
|
)
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
|
||||||
|
val dismiss = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
val notificationManager = resolveNotificationManager(appContext)
|
||||||
|
val allBundles = calls.map { it.toBundle() }
|
||||||
|
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
|
||||||
|
val bundles = if (targeted) {
|
||||||
|
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
|
||||||
|
} else {
|
||||||
|
allBundles
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeMatches = activeCallBundle?.let { bundle ->
|
||||||
|
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
|
||||||
|
} == true
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted || bundles.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
CallkitNotificationService.stopService(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted) {
|
||||||
|
try {
|
||||||
|
removeAllCalls(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
removeCall(appContext, Data.fromBundle(bundle))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopIncomingRingtone()
|
||||||
|
}
|
||||||
|
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
dismiss.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
|
dismissIncomingCalls(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
|
||||||
|
dismissIncomingCalls(
|
||||||
|
context = context,
|
||||||
|
callId = callId(data),
|
||||||
|
conversationUuid = conversationUuid(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureIncomingCallCallbackRegistered() {
|
||||||
|
if (callbackRegistered) return
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||||
|
callbackRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
|
||||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||||
activeSoundManager = soundManager
|
activeSoundManager = soundManager
|
||||||
activeNotificationManager = notificationManager
|
activeNotificationManager = notificationManager
|
||||||
|
activeCallBundle = callData
|
||||||
notificationManager.createNotificationChanel(callData)
|
notificationManager.createNotificationChanel(callData)
|
||||||
notificationManager.showIncomingNotification(callData)
|
notificationManager.showIncomingNotification(callData)
|
||||||
|
|
||||||
@@ -109,46 +311,78 @@ object VideoCallPushBridge {
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
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
|
* Turns the screen on while the call rings. The full-screen intent of the
|
||||||
val calls = getDataActiveCalls(appContext)
|
* notification does the same only when the system grants it; the wake lock
|
||||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
* 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)
|
private fun stopPlaybackLocked(appContext: Context) {
|
||||||
val bundles = calls.map { it.toBundle() }
|
cancelRingtoneTimeoutLocked()
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
for (bundle in bundles) {
|
val notificationManager = activeNotificationManager
|
||||||
|
val bundle = activeCallBundle
|
||||||
|
stopIncomingRingtone()
|
||||||
|
if (notificationManager != null && bundle != null) {
|
||||||
try {
|
try {
|
||||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(
|
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||||
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(appContext)
|
|
||||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
|
||||||
activeSoundManager = null
|
activeSoundManager = null
|
||||||
activeNotificationManager = null
|
activeNotificationManager = null
|
||||||
|
activeCallBundle = null
|
||||||
|
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingtoneTimeoutLocked() {
|
||||||
|
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringtoneTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||||
@@ -161,11 +395,19 @@ object VideoCallPushBridge {
|
|||||||
return CallkitNotificationManager(context, soundManager)
|
return CallkitNotificationManager(context, soundManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopIncomingRingtone(context: Context) {
|
private fun stopIncomingRingtone() {
|
||||||
activeSoundManager?.stop()
|
try {
|
||||||
FlutterCallkitIncomingPlugin.getInstance()
|
activeSoundManager?.stop()
|
||||||
?.getCallkitSoundPlayerManager()
|
} catch (error: Exception) {
|
||||||
?.stop()
|
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FlutterCallkitIncomingPlugin.getInstance()
|
||||||
|
?.getCallkitSoundPlayerManager()
|
||||||
|
?.stop()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,6 +464,8 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
||||||
val conversationUuid = conversationUuid(data) ?: return null
|
val conversationUuid = conversationUuid(data) ?: return null
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
val callKitId = serverCallId ?: conversationUuid
|
||||||
val callerName = data["counterpartName"]
|
val callerName = data["counterpartName"]
|
||||||
?: data["counterpart_name"]
|
?: data["counterpart_name"]
|
||||||
?: data["caller_name"]
|
?: data["caller_name"]
|
||||||
@@ -247,6 +491,9 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
val extra = HashMap<String, Any?>()
|
val extra = HashMap<String, Any?>()
|
||||||
extra["conversationUuid"] = conversationUuid
|
extra["conversationUuid"] = conversationUuid
|
||||||
|
if (!serverCallId.isNullOrBlank()) {
|
||||||
|
extra["call_id"] = serverCallId
|
||||||
|
}
|
||||||
extra["counterpartName"] = callerName
|
extra["counterpartName"] = callerName
|
||||||
if (avatarUrl.isNotBlank()) {
|
if (avatarUrl.isNotBlank()) {
|
||||||
extra["counterpartAvatarUrl"] = avatarUrl
|
extra["counterpartAvatarUrl"] = avatarUrl
|
||||||
@@ -257,9 +504,13 @@ object VideoCallPushBridge {
|
|||||||
if (counterpartIsConsultant) {
|
if (counterpartIsConsultant) {
|
||||||
extra["counterpartIsConsultant"] = true
|
extra["counterpartIsConsultant"] = true
|
||||||
}
|
}
|
||||||
|
val messageId = data["message_id"] ?: data["messageId"]
|
||||||
|
if (!messageId.isNullOrBlank()) {
|
||||||
|
extra["message_id"] = messageId
|
||||||
|
}
|
||||||
|
|
||||||
return Bundle().apply {
|
return Bundle().apply {
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||||
@@ -284,6 +535,77 @@ object VideoCallPushBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun callId(data: Map<String, String>): String? {
|
||||||
|
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
|
||||||
|
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundleMatches(
|
||||||
|
bundle: Bundle,
|
||||||
|
callId: String?,
|
||||||
|
conversationUuid: String?,
|
||||||
|
): Boolean {
|
||||||
|
val normalizedCallId = callId?.trim()?.lowercase()
|
||||||
|
if (!normalizedCallId.isNullOrEmpty()) {
|
||||||
|
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
|
||||||
|
if (id != null && id == normalizedCallId) return true
|
||||||
|
val extraCallId = extraString(bundle, "call_id")
|
||||||
|
?: extraString(bundle, "callId")
|
||||||
|
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A ring that already stores its own call id must not be closed by
|
||||||
|
// another call in the same chat. Legacy rings have no call id and
|
||||||
|
// still match the conversation.
|
||||||
|
if (!extraCallId.isNullOrBlank()) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||||
|
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
|
||||||
|
if (handle != null && handle == normalizedConversation) return true
|
||||||
|
val extraConversation = extraString(bundle, "conversationUuid")
|
||||||
|
?: extraString(bundle, "conversation_uuid")
|
||||||
|
return extraConversation?.lowercase() == normalizedConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun extraString(bundle: Bundle, key: String): String? {
|
||||||
|
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
|
||||||
|
?: return null
|
||||||
|
val value = extra[key] ?: return null
|
||||||
|
val text = value.toString().trim()
|
||||||
|
return text.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markTombstone(callId: String) {
|
||||||
|
val normalized = callId.trim().lowercase()
|
||||||
|
if (normalized.isEmpty()) return
|
||||||
|
pruneTombstones()
|
||||||
|
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTombstoned(callId: String?): Boolean {
|
||||||
|
val normalized = callId?.trim()?.lowercase() ?: return false
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
pruneTombstones()
|
||||||
|
return tombstonedCallIds.containsKey(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneTombstones() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val iterator = tombstonedCallIds.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val entry = iterator.next()
|
||||||
|
if (entry.value <= now) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun conversationUuid(data: Map<String, String>): String? {
|
private fun conversationUuid(data: Map<String, String>): String? {
|
||||||
val fromData = data["conversationUuid"]
|
val fromData = data["conversationUuid"]
|
||||||
?: data["conversation_uuid"]
|
?: data["conversation_uuid"]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VideoCallDeclineRequestTest {
|
||||||
|
private val baseUrl = "https://eatvio.example"
|
||||||
|
private val token = "sanctum-token"
|
||||||
|
private val hwId = "install-hw-id-01"
|
||||||
|
private val conversation = "11111111-1111-1111-1111-111111111111"
|
||||||
|
private val callId = "22222222-2222-2222-2222-222222222222"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsDeclinePostWithCallId() {
|
||||||
|
val request = request(callId = callId)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
assertEquals(token, request?.token)
|
||||||
|
assertEquals(
|
||||||
|
"""{"hw_id":"$hwId","call_id":"$callId"}""",
|
||||||
|
request?.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdWhenItIsTheConversationUuid() {
|
||||||
|
val request = request(callId = conversation.uppercase())
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdThatIsNotAUuid() {
|
||||||
|
val request = request(callId = "message-15")
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsConversationFromHandleWhenExtraHasNone() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
|
||||||
|
assertTrue(request?.url?.contains(conversation) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsEndedPush() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
conversation,
|
||||||
|
mapOf("conversationUuid" to conversation, "endedPush" to true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsMissingTokenBaseUrlOrShortHwId() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation, "call_id" to callId),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trimsATrailingSlashOnTheBaseUrl() {
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
"$baseUrl/",
|
||||||
|
token,
|
||||||
|
hwId,
|
||||||
|
VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(callId: String?): VideoCallDeclineRequest? {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf(
|
||||||
|
"conversationUuid" to conversation,
|
||||||
|
"call_id" to callId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -650,7 +650,7 @@ code + .copy-button {
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function configurationCacheProblems() { return (
|
function configurationCacheProblems() { return (
|
||||||
// begin-report-data
|
// begin-report-data
|
||||||
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||||
// end-report-data
|
// end-report-data
|
||||||
);}
|
);}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
|
||||||
|
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +119,8 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
|
||||||
|
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||||
);
|
);
|
||||||
@@ -300,6 +306,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,27 @@
|
|||||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
<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>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
86
ios/Runner/SceneDelegate.swift
Normal file
86
ios/Runner/SceneDelegate.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum VideoCallDeclineClient {
|
||||||
|
private static let uuidPattern =
|
||||||
|
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
|
||||||
|
|
||||||
|
/// `POST /video-call/decline` from the CallKit callback. The process is
|
||||||
|
/// awake here even when the user never opens the app afterwards.
|
||||||
|
static func send(extra: [String: Any], handle: String) {
|
||||||
|
let fields = declineFields(extra: extra, handle: handle)
|
||||||
|
guard let request = makeRequest(
|
||||||
|
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
|
||||||
|
token: prefsString("token"),
|
||||||
|
hwId: UIDevice.current.identifierForVendor?.uuidString,
|
||||||
|
fields: fields
|
||||||
|
) else {
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = EatvioBackgroundTask()
|
||||||
|
task.begin(name: "video-call-decline")
|
||||||
|
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
|
||||||
|
)
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
urlTask.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeRequest(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields
|
||||||
|
) -> URLRequest? {
|
||||||
|
if fields.endedPush { return nil }
|
||||||
|
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
while root.hasSuffix("/") {
|
||||||
|
root.removeLast()
|
||||||
|
}
|
||||||
|
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
|
||||||
|
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
|
||||||
|
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
|
||||||
|
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var body: [String: String] = ["hw_id": deviceId]
|
||||||
|
if let callId = fields.callId {
|
||||||
|
body["call_id"] = callId
|
||||||
|
}
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
|
||||||
|
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
|
||||||
|
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: backendCallId(rawCallId, conversation: conversation),
|
||||||
|
endedPush: marksEndedPush(extra["endedPush"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
|
||||||
|
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
|
||||||
|
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
||||||
|
else { return nil }
|
||||||
|
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
guard let value = extra[key] as? String else { continue }
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func marksEndedPush(_ value: Any?) -> Bool {
|
||||||
|
if let bool = value as? Bool { return bool }
|
||||||
|
if let number = value as? NSNumber { return number.intValue != 0 }
|
||||||
|
if let text = value as? String {
|
||||||
|
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prefsString(_ key: String) -> String? {
|
||||||
|
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let value, !value.isEmpty { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoCallDeclineFields {
|
||||||
|
var conversationUuid: String
|
||||||
|
var callId: String?
|
||||||
|
var endedPush: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class EatvioBackgroundTask {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
func begin(name: String) {
|
||||||
|
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
self?.end()
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
id = taskId
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
lock.lock()
|
||||||
|
let current = id
|
||||||
|
id = .invalid
|
||||||
|
lock.unlock()
|
||||||
|
guard current != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||||
import 'package:eatvio_app/core/env_config.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/internet_connection_cubit/internet_connection_cubit.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.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/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/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
@@ -31,20 +35,51 @@ class AppConfig {
|
|||||||
|
|
||||||
void setBaseUrl(String baseUrls) {
|
void setBaseUrl(String baseUrls) {
|
||||||
_baseUrl = 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));
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
BlocProvider<VideoCallSessionCubit>.value(
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
value: sl<VideoCallSessionCubit>(),
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
),
|
||||||
|
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<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<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
BlocProvider<InternetConnectionCubit>(
|
||||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
lazy: false,
|
||||||
|
create: (context) => sl<InternetConnectionCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<MarketingCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<MarketingCubit>(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Делегаты локализации
|
/// Делегаты локализации
|
||||||
@@ -56,5 +91,7 @@ class AppConfig {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Поддерживаемые локали
|
/// Поддерживаемые локали
|
||||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
List<Locale> get supportedLocales => [
|
||||||
|
const Locale.fromSubtags(languageCode: 'ru'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
30
lib/core/cached_base_url.dart
Normal file
30
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
|
|
||||||
|
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||||
|
///
|
||||||
|
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
|
||||||
|
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
|
||||||
|
void applyCachedBaseUrl() {
|
||||||
|
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
sl<AppConfig>().setBaseUrl(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistApiBaseUrl(String baseUrl) {
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
|
}
|
||||||
@@ -1,16 +1,70 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Stable id of this install, sent as `hw_id` on push registration and on
|
||||||
|
/// video-call accept/decline.
|
||||||
|
///
|
||||||
|
/// Android `Build.ID` is the firmware id. Two phones on the same build share
|
||||||
|
/// it, so the backend treats them as one device and a decline on one does not
|
||||||
|
/// reach the other. A per-install id stays unique.
|
||||||
abstract final class DeviceHwIdProvider {
|
abstract final class DeviceHwIdProvider {
|
||||||
static Future<String?> current() async {
|
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
|
||||||
|
|
||||||
|
static Future<String?> current({SharedPreferences? preferences}) async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
return (await deviceInfo.iosInfo).identifierForVendor;
|
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
return (await deviceInfo.androidInfo).id;
|
return androidInstallId(preferences: preferences);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> androidInstallId({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(androidInstallIdKey)?.trim();
|
||||||
|
if (existing != null && existing.length >= 8) return existing;
|
||||||
|
|
||||||
|
final created = _newInstallId();
|
||||||
|
await prefs.setString(androidInstallIdKey, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous Android registration used `Build.ID`. Drop that token once
|
||||||
|
/// and register again with the install id.
|
||||||
|
static Future<bool> androidTokenNeedsReregister({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) return false;
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(androidTokenMigratedKey) != true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> markAndroidTokenReregistered({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(androidTokenMigratedKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _newInstallId() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '${hex.substring(0, 8)}-'
|
||||||
|
'${hex.substring(8, 12)}-'
|
||||||
|
'${hex.substring(12, 16)}-'
|
||||||
|
'${hex.substring(16, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
@@ -44,15 +45,16 @@ class FirebaseApi {
|
|||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
try {
|
try {
|
||||||
await _requestNotificationPermission();
|
await _requestNotificationPermission();
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
|
||||||
final credentials = await _resolvePushCredentials();
|
final credentials = await _resolvePushCredentials();
|
||||||
final token = credentials.token;
|
final token = credentials.token;
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/anonymous_token',
|
'/api/anonymous_token',
|
||||||
data: {
|
data: {
|
||||||
'platform': credentials.platform,
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': hwId,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
@@ -69,34 +71,52 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
await _requestNotificationPermission();
|
await _requestNotificationPermission();
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final credentials = await _resolvePushCredentials();
|
||||||
final credentials = await _resolvePushCredentials();
|
final token = credentials.token;
|
||||||
final token = credentials.token;
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
if (token == null || token.isEmpty) return;
|
final reregisterAndroid =
|
||||||
await sl<Dio>().post(
|
await DeviceHwIdProvider.androidTokenNeedsReregister();
|
||||||
'/api/profile/notification/token/register',
|
if (token != null &&
|
||||||
data: {
|
token.isNotEmpty &&
|
||||||
'platform': credentials.platform,
|
hwId != null &&
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
hwId.length >= 8) {
|
||||||
'token': token,
|
if (reregisterAndroid) {
|
||||||
},
|
try {
|
||||||
).timeout(
|
await unregisterPushToken();
|
||||||
const Duration(seconds: 10),
|
} catch (_) {}
|
||||||
onTimeout: () {
|
}
|
||||||
throw TimeoutException('registerPushToken timeout');
|
await sl<Dio>()
|
||||||
},
|
.post(
|
||||||
);
|
'/api/profile/notification/token/register',
|
||||||
if (Platform.isIOS) {
|
data: {
|
||||||
await _registerIosVoipPushToken();
|
'platform': credentials.platform,
|
||||||
|
'hw_id': hwId,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('registerPushToken timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (reregisterAndroid) {
|
||||||
|
await DeviceHwIdProvider.markAndroidTokenReregistered();
|
||||||
}
|
}
|
||||||
} 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,12 +162,13 @@ class FirebaseApi {
|
|||||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/voip/register',
|
'/api/profile/notification/token/voip/register',
|
||||||
data: {
|
data: {
|
||||||
'platform': 'ios',
|
'platform': 'ios',
|
||||||
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
'hw_id': hwId,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
|
|||||||
class PhoneVerifyErrorInterceptor extends Interceptor {
|
class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 405) {
|
if (err.response?.statusCode != 405) {
|
||||||
final data = err.response?.data;
|
return handler.next(err);
|
||||||
|
}
|
||||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
|
||||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
final requirement = VerificationRequirement.tryParse(err.response?.data);
|
||||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
if (requirement == null) {
|
||||||
await showErrorDialog(
|
return handler.next(err);
|
||||||
AppRouter.navigatorKey.currentContext!,
|
}
|
||||||
title: 'Привяжите номер телефона',
|
|
||||||
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
|
||||||
);
|
if (requirement.needsEmail) {
|
||||||
final currentContext = AppRouter.navigatorKey.currentContext;
|
await _handleEmailVerificationRequired();
|
||||||
|
} else {
|
||||||
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
|
await _handlePhoneVerificationRequired();
|
||||||
GoRouter.of(currentContext).pop();
|
}
|
||||||
}
|
|
||||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
return handler.reject(
|
||||||
return handler.reject(
|
DioException(
|
||||||
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
|
requestOptions: err.requestOptions,
|
||||||
);
|
response: err.response,
|
||||||
}
|
error: 'Phone number not verified',
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
|
||||||
|
Future<void> _handlePhoneVerificationRequired() async {
|
||||||
|
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||||
|
final String passKey = await sl<ProfileRepository>().getPassKey(
|
||||||
|
routeName: 'user_detailz.change-phone-verify',
|
||||||
|
routeParams: {"phone": phone},
|
||||||
|
);
|
||||||
|
await showErrorDialog(
|
||||||
|
AppRouter.navigatorKey.currentContext!,
|
||||||
|
title: 'Привяжите номер телефона',
|
||||||
|
description:
|
||||||
|
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||||
|
);
|
||||||
|
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (currentContext != null &&
|
||||||
|
currentContext.mounted &&
|
||||||
|
GoRouter.of(currentContext).state.path ==
|
||||||
|
RouteConstants.stepperBecomeConsultantScreen) {
|
||||||
|
GoRouter.of(currentContext).pop();
|
||||||
|
}
|
||||||
|
AppRouter.navigatorKey.currentContext?.pushNamed(
|
||||||
|
'verifyScreen',
|
||||||
|
extra: passKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
|
||||||
|
Future<void> _handleEmailVerificationRequired() async {
|
||||||
|
final context = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (context == null || !context.mounted) {
|
||||||
|
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showErrorDialog(
|
||||||
|
context,
|
||||||
|
title: 'Подтвердите e-mail',
|
||||||
|
description:
|
||||||
|
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pushNamed('verifyEmailScreen', extra: true);
|
||||||
}
|
}
|
||||||
return handler.next(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// Канал верификации, которого не хватает для публичного действия.
|
||||||
|
enum VerificationChannel { phone, email }
|
||||||
|
|
||||||
|
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||||
|
///
|
||||||
|
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||||
|
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||||
|
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||||
|
class VerificationRequirement {
|
||||||
|
const VerificationRequirement({required this.missing});
|
||||||
|
|
||||||
|
/// Каналы, подтверждение которых требует бэкенд.
|
||||||
|
final List<VerificationChannel> missing;
|
||||||
|
|
||||||
|
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||||
|
|
||||||
|
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||||
|
|
||||||
|
/// Возвращает null, если ответ не является отказом верификации.
|
||||||
|
static VerificationRequirement? tryParse(Object? data) {
|
||||||
|
if (data is! Map) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data['type'] != 'unverificated_phone') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? rawMissing = data['missing'];
|
||||||
|
if (rawMissing is! List) {
|
||||||
|
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPhone = false;
|
||||||
|
var hasEmail = false;
|
||||||
|
for (final Object? value in rawMissing) {
|
||||||
|
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||||
|
if (value == 'phone') {
|
||||||
|
hasPhone = true;
|
||||||
|
} else if (value == 'email') {
|
||||||
|
hasEmail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationRequirement(
|
||||||
|
missing: [
|
||||||
|
if (hasPhone) VerificationChannel.phone,
|
||||||
|
if (hasEmail) VerificationChannel.email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
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/app_config_registration.dart';
|
||||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/external_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>();
|
await sl.getAsync<Prefs>();
|
||||||
|
// До создания Dio и любых запросов к бэкенду.
|
||||||
|
applyCachedBaseUrl();
|
||||||
|
|
||||||
// Регистрация сервисов
|
// Регистрация сервисов
|
||||||
ServiceRegistrations.registerAll();
|
ServiceRegistrations.registerAll();
|
||||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
CubitRegistrations.registerAll();
|
CubitRegistrations.registerAll();
|
||||||
|
|
||||||
// Регистрация WebSocket менеджера
|
// Регистрация WebSocket менеджера
|
||||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
sl.registerLazySingleton<WsManager>(
|
||||||
|
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,18 @@ class PushNavigationHandler {
|
|||||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
if (endedPayload != null) {
|
if (endedPayload != null) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
'PushNavigationHandler: dismiss incoming call UI, source=$source '
|
||||||
|
'call_id=${endedPayload.callId ?? '-'} '
|
||||||
|
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||||
name: 'PushNavigationHandler',
|
name: 'PushNavigationHandler',
|
||||||
);
|
);
|
||||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: endedPayload.conversationUuid,
|
||||||
|
callId: endedPayload.callId,
|
||||||
|
reason: endedPayload.reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
enum PrefsKeys {
|
||||||
|
hasAnonymousTokenBeenSent,
|
||||||
|
welcomeScreenWasShowed,
|
||||||
|
registrationDate,
|
||||||
|
marketingBottomSheetHasBeenShown,
|
||||||
|
isEmailVerificationSkipped,
|
||||||
|
customBaseUrl,
|
||||||
|
apiBaseUrl,
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class AuthRoutes {
|
|||||||
name: 'verifyScreen',
|
name: 'verifyScreen',
|
||||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.registerViaSocialScreen,
|
path: RouteConstants.registerViaSocialScreen,
|
||||||
name: 'registerViaSocialScreen',
|
name: 'registerViaSocialScreen',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class WsManager {
|
|||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
|
||||||
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
||||||
: _errorReporter = errorReporter;
|
: _errorReporter = errorReporter;
|
||||||
|
|
||||||
PusherChannelsClient? client;
|
PusherChannelsClient? client;
|
||||||
int? userId;
|
int? userId;
|
||||||
@@ -43,14 +43,32 @@ class WsManager {
|
|||||||
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||||
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||||
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||||
StreamController<void> _reconnectController = StreamController<void>.broadcast();
|
StreamController<void> _reconnectController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? _directChatStream;
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream =>
|
||||||
|
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
/// The callee rejected the ring. Broadcast to the caller and the callee.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-declined');
|
||||||
|
|
||||||
|
/// The call was cancelled or hung up. Same channel the web client uses.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallEndedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-ended');
|
||||||
|
|
||||||
|
/// Another device accepted the ring. Ringing on this device must stop,
|
||||||
|
/// except on the device that accepted.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-accepted');
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get newNotificationsStream =>
|
||||||
|
_privateNotificationChannel?.bind(
|
||||||
|
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
|
||||||
|
);
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
@@ -86,15 +104,9 @@ class WsManager {
|
|||||||
connectionErrorHandler: (exception, trace, refresh) {
|
connectionErrorHandler: (exception, trace, refresh) {
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
minimumReconnectDelayDuration: const Duration(
|
minimumReconnectDelayDuration: const Duration(seconds: 1),
|
||||||
seconds: 1,
|
defaultActivityDuration: const Duration(seconds: 30),
|
||||||
),
|
waitForPongDuration: const Duration(seconds: 30),
|
||||||
defaultActivityDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
waitForPongDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String? extractSocketId(String? data) {
|
String? extractSocketId(String? data) {
|
||||||
@@ -107,7 +119,9 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
|
_connectionEstablishedSubscription = client?.eventStream.listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
if (event.name == 'pusher:connection_established') {
|
if (event.name == 'pusher:connection_established') {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final socketId = extractSocketId(data);
|
final socketId = extractSocketId(data);
|
||||||
@@ -124,7 +138,8 @@ class WsManager {
|
|||||||
await _initPrivateMessagesChannel();
|
await _initPrivateMessagesChannel();
|
||||||
|
|
||||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data ==
|
||||||
|
PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
handleConnectionEstablished();
|
handleConnectionEstablished();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -166,12 +181,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateNotificationChannel = client?.privateChannel(
|
_privateNotificationChannel = client?.privateChannel(
|
||||||
'$newNotificationsChannelName.$userId',
|
'$newNotificationsChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,12 +195,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateMessagesChannel = client?.privateChannel(
|
_privateMessagesChannel = client?.privateChannel(
|
||||||
'$newMessagesChannelName.$userId',
|
'$newMessagesChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +210,17 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_directChatStream = _privateDirectChatChannel?.bind(
|
||||||
|
'App\\Events\\ChatMessageUpdated',
|
||||||
);
|
);
|
||||||
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
|
||||||
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +228,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_presenceDirectChatChannel = client?.presenceChannel(
|
_presenceDirectChatChannel = client?.presenceChannel(
|
||||||
'$presenceDirectChatChannelName.$chatId',
|
'$presenceDirectChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +261,8 @@ class WsManager {
|
|||||||
void Function(bool isOnline) onCounterpartOnlineChanged,
|
void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||||
) {
|
) {
|
||||||
_presenceDirectChatListeners.removeWhere(
|
_presenceDirectChatListeners.removeWhere(
|
||||||
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
(listener) =>
|
||||||
|
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
||||||
);
|
);
|
||||||
if (_presenceDirectChatListeners.isEmpty) {
|
if (_presenceDirectChatListeners.isEmpty) {
|
||||||
unsubscribePresenceDirectChatChannel();
|
unsubscribePresenceDirectChatChannel();
|
||||||
@@ -254,21 +276,26 @@ class WsManager {
|
|||||||
|
|
||||||
_presenceChannelEventsBound = true;
|
_presenceChannelEventsBound = true;
|
||||||
|
|
||||||
_presenceSubscriptionSucceededSubscription =
|
_presenceSubscriptionSucceededSubscription = channel
|
||||||
channel.whenSubscriptionSucceeded().listen((_) {
|
.whenSubscriptionSucceeded()
|
||||||
final counterpartIds = _presenceDirectChatListeners
|
.listen((_) {
|
||||||
.map((listener) => listener.counterpartUserId)
|
final counterpartIds = _presenceDirectChatListeners
|
||||||
.toSet();
|
.map((listener) => listener.counterpartUserId)
|
||||||
for (final counterpartUserId in counterpartIds) {
|
.toSet();
|
||||||
_emitCounterpartPresence(counterpartUserId);
|
for (final counterpartUserId in counterpartIds) {
|
||||||
}
|
_emitCounterpartPresence(counterpartUserId);
|
||||||
});
|
}
|
||||||
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
|
});
|
||||||
|
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: true);
|
_notifyPresenceListeners(memberUserId, isOnline: true);
|
||||||
});
|
});
|
||||||
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
|
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: false);
|
_notifyPresenceListeners(memberUserId, isOnline: false);
|
||||||
@@ -280,11 +307,15 @@ class WsManager {
|
|||||||
if (channel == null) return;
|
if (channel == null) return;
|
||||||
final members = channel.state?.members;
|
final members = channel.state?.members;
|
||||||
if (members == null) return;
|
if (members == null) return;
|
||||||
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
|
final isOnline =
|
||||||
|
members.getMemberInfo(counterpartUserId.toString()) != null;
|
||||||
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
|
void _notifyPresenceListeners(
|
||||||
|
int counterpartUserId, {
|
||||||
|
required bool isOnline,
|
||||||
|
}) {
|
||||||
for (final listener in _presenceDirectChatListeners) {
|
for (final listener in _presenceDirectChatListeners) {
|
||||||
if (listener.counterpartUserId == counterpartUserId) {
|
if (listener.counterpartUserId == counterpartUserId) {
|
||||||
listener.onCounterpartOnlineChanged(isOnline);
|
listener.onCounterpartOnlineChanged(isOnline);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||||
@@ -10,7 +11,9 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_sta
|
|||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -23,11 +26,19 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
StreamSubscription? newIncomingMessagesSubscription;
|
StreamSubscription? newIncomingMessagesSubscription;
|
||||||
StreamSubscription? newLastMessageSubscription;
|
StreamSubscription? newLastMessageSubscription;
|
||||||
StreamSubscription<void>? reconnectedSubscription;
|
StreamSubscription<void>? reconnectedSubscription;
|
||||||
|
StreamSubscription? videoCallDeclinedSubscription;
|
||||||
|
StreamSubscription? videoCallEndedSubscription;
|
||||||
|
StreamSubscription? videoCallAcceptedSubscription;
|
||||||
final Set<int> _presentedVideoCallMessageIds = {};
|
final Set<int> _presentedVideoCallMessageIds = {};
|
||||||
|
|
||||||
/// Test seam. Production uses CallKit.
|
/// Test seam. Production uses CallKit.
|
||||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||||
Future<void> Function()? dismissIncomingVideoCall;
|
|
||||||
|
/// Test seam. Production reads [DeviceHwIdProvider.current].
|
||||||
|
Future<String?> Function()? currentDeviceHwId;
|
||||||
|
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||||
|
Future<void> Function({String? conversationUuid, String? callId})?
|
||||||
|
endActiveVideoCall;
|
||||||
|
|
||||||
ChatsCubit({
|
ChatsCubit({
|
||||||
required this.chatRepository,
|
required this.chatRepository,
|
||||||
@@ -55,6 +66,37 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
_handleNewMessageViaSocket(event.data);
|
_handleNewMessageViaSocket(event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseDeclined(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await videoCallAcceptedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseEnded(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await reconnectedSubscription?.cancel();
|
await reconnectedSubscription?.cancel();
|
||||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||||
unawaited(getChatRooms());
|
unawaited(getChatRooms());
|
||||||
@@ -170,6 +212,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
try {
|
try {
|
||||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
final callId = _stringField(json, const ['call_id', 'callId']);
|
||||||
|
|
||||||
final currentUserId = wsManager.userId;
|
final currentUserId = wsManager.userId;
|
||||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
@@ -217,6 +260,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
message,
|
message,
|
||||||
isOwn: isOwn,
|
isOwn: isOwn,
|
||||||
chatRooms: currentChatRooms,
|
chatRooms: currentChatRooms,
|
||||||
|
callId: callId,
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -227,10 +271,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
ChatMessageModel message, {
|
ChatMessageModel message, {
|
||||||
required bool isOwn,
|
required bool isOwn,
|
||||||
required List<ChatRoom> chatRooms,
|
required List<ChatRoom> chatRooms,
|
||||||
|
String? callId,
|
||||||
}) {
|
}) {
|
||||||
final kind = VideoCallChatSignal.kindOf(message);
|
final kind = VideoCallChatSignal.kindOf(message);
|
||||||
if (kind == VideoCallChatSignalKind.ended) {
|
if (kind == VideoCallChatSignalKind.ended) {
|
||||||
unawaited(_dismissIncomingVideoCallUi());
|
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||||
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: message.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||||
@@ -243,6 +293,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
message: message,
|
message: message,
|
||||||
callerName: caller?.name,
|
callerName: caller?.name,
|
||||||
callerAvatarUrl: caller?.profilePhotoUrl,
|
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||||
|
callId: callId,
|
||||||
);
|
);
|
||||||
if (payload == null) return;
|
if (payload == null) return;
|
||||||
unawaited(_showIncomingVideoCall(payload));
|
unawaited(_showIncomingVideoCall(payload));
|
||||||
@@ -257,10 +308,45 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _dismissIncomingVideoCallUi() async {
|
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
|
||||||
|
if (signal == null) return;
|
||||||
|
if (await _acceptedOnThisDevice(signal)) return;
|
||||||
|
if (signal.endsActiveCall) {
|
||||||
|
final endActive = endActiveVideoCall;
|
||||||
|
if (endActive != null) {
|
||||||
|
await endActive(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final dismiss = dismissIncomingVideoCall;
|
||||||
|
if (dismiss != null) {
|
||||||
|
await dismiss(signal.conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
reason: signal.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||||
try {
|
try {
|
||||||
await (dismissIncomingVideoCall ??
|
final dismiss = dismissIncomingVideoCall;
|
||||||
VideoCallEndedPushHandler.dismissIncomingCallUi)();
|
if (dismiss != null) {
|
||||||
|
await dismiss(conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log(
|
log(
|
||||||
'ChatsCubit: failed to dismiss incoming video call: $e',
|
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||||
@@ -269,6 +355,34 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `video-call-accepted` also reaches the device that just answered.
|
||||||
|
/// Closing CallKit there hangs up the call that is starting.
|
||||||
|
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
|
||||||
|
if (signal.reason != VideoCallEndReason.accepted) return false;
|
||||||
|
final acceptedId = signal.acceptedDeviceId?.trim();
|
||||||
|
if (acceptedId == null || acceptedId.isEmpty) return false;
|
||||||
|
try {
|
||||||
|
final current =
|
||||||
|
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
|
||||||
|
if (current == null || current.isEmpty) return false;
|
||||||
|
return current.toLowerCase() == acceptedId.toLowerCase();
|
||||||
|
} catch (e, st) {
|
||||||
|
log(
|
||||||
|
'ChatsCubit: failed to read hw id for accepted call: $e',
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stringField(Map<String, dynamic> json, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
ChatUser? _callerFromRooms(
|
ChatUser? _callerFromRooms(
|
||||||
ChatMessageModel message,
|
ChatMessageModel message,
|
||||||
List<ChatRoom> chatRooms,
|
List<ChatRoom> chatRooms,
|
||||||
@@ -297,6 +411,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
newIncomingMessagesSubscription?.cancel();
|
newIncomingMessagesSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription?.cancel();
|
||||||
reconnectedSubscription?.cancel();
|
reconnectedSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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/plans/enums/chosen_plan_state_enum.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.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/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
ChatRoomStateLoaded state,
|
ChatRoomStateLoaded state,
|
||||||
ChatUserModel chatCounterpart,
|
ChatUserModel chatCounterpart,
|
||||||
) {
|
) {
|
||||||
|
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
VideoCallScreen.name,
|
VideoCallScreen.name,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ enum PortionState {
|
|||||||
@JsonValue('eaten_edited')
|
@JsonValue('eaten_edited')
|
||||||
eatenEdited,
|
eatenEdited,
|
||||||
commented,
|
commented,
|
||||||
|
@JsonValue('commented_from_diet')
|
||||||
|
commentedFromDiet,
|
||||||
@JsonValue('reviewed_by_user')
|
@JsonValue('reviewed_by_user')
|
||||||
reviewedByUser,
|
reviewedByUser,
|
||||||
|
@JsonValue('reviewed_from_diet')
|
||||||
|
reviewedFromDiet,
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
|
|||||||
@JsonKey(name: "quantity") required int quantity,
|
@JsonKey(name: "quantity") required int quantity,
|
||||||
@JsonKey(name: "portion") required String portion,
|
@JsonKey(name: "portion") required String portion,
|
||||||
@JsonKey(name: "from_diet") required bool fromDiet,
|
@JsonKey(name: "from_diet") required bool fromDiet,
|
||||||
@JsonKey(name: "state") required PortionState portionState,
|
@JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
|
||||||
}) = _PortionModel;
|
}) = _PortionModel;
|
||||||
|
|
||||||
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);
|
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);
|
||||||
|
|||||||
@@ -162,17 +162,18 @@ class MenuScreen extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
// Logout revokes the Sanctum token. Unregister first, or
|
||||||
|
// FCM / VoIP tokens stay on the server and calls still ring.
|
||||||
|
await sl<FirebaseApi>().unregisterPushTokens();
|
||||||
|
} catch (e, s) {
|
||||||
|
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
|
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
|
||||||
// Needs Authorization header → must run before removing token.
|
|
||||||
await sl<FirebaseApi>().unregisterPushTokens();
|
|
||||||
} catch (e, s) {
|
|
||||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
|
||||||
}
|
|
||||||
await sl<VideoCallSessionCubit>().endCall();
|
await sl<VideoCallSessionCubit>().endCall();
|
||||||
await sl<WsManager>().clear();
|
await sl<WsManager>().clear();
|
||||||
await TokenStorage.removeTokenInStorage();
|
await TokenStorage.removeTokenInStorage();
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class VerifyEmailScreen extends StatefulWidget {
|
class VerifyEmailScreen extends StatefulWidget {
|
||||||
const VerifyEmailScreen({super.key});
|
const VerifyEmailScreen({super.key, this.returnOnSuccess = false});
|
||||||
|
|
||||||
|
/// Вернуться на предыдущий экран вместо перехода в ленту после подтверждения.
|
||||||
|
/// Используется, когда экран открыт из публичного действия (пост, лайк, комментарий).
|
||||||
|
final bool returnOnSuccess;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
|
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
|
||||||
@@ -89,7 +93,12 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
|||||||
_startTimer();
|
_startTimer();
|
||||||
} else if (state is VerifyEmailStateVerifySuccess) {
|
} else if (state is VerifyEmailStateVerifySuccess) {
|
||||||
showSuccessToast('Почта успешно подтверждена');
|
showSuccessToast('Почта успешно подтверждена');
|
||||||
goRouter.pushNamed('feedScreen');
|
if (widget.returnOnSuccess) {
|
||||||
|
// Экран открыт из публичного действия — возвращаемся назад с результатом.
|
||||||
|
goRouter.pop(true);
|
||||||
|
} else {
|
||||||
|
goRouter.pushNamed('feedScreen');
|
||||||
|
}
|
||||||
} else if (state is VerifyEmailStateVerifyError) {
|
} else if (state is VerifyEmailStateVerifyError) {
|
||||||
showErrorToast("Неверный код. Попробуйте снова.");
|
showErrorToast("Неверный код. Попробуйте снова.");
|
||||||
for (var controller in _controllers) {
|
for (var controller in _controllers) {
|
||||||
@@ -166,18 +175,20 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CustomOutlinedButton(
|
// При переходе из публичного действия уйти без подтверждения нельзя.
|
||||||
text: 'Позже',
|
if (!widget.returnOnSuccess)
|
||||||
height: 52,
|
CustomOutlinedButton(
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
|
text: 'Позже',
|
||||||
buttonStyle: ButtonStyle(
|
height: 52,
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<VerifyEmailCubit>().skipVerification();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onPressed: () {
|
|
||||||
context.read<VerifyEmailCubit>().skipVerification();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
@@ -8,7 +9,9 @@ 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_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_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_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_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
|
||||||
@@ -16,6 +19,7 @@ import 'package:eatvio_app/features/video_calls/repositories/video_call_reposito
|
|||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -30,13 +34,22 @@ class VideoCallManager {
|
|||||||
VideoCallForegroundCoordinator? foregroundCoordinator,
|
VideoCallForegroundCoordinator? foregroundCoordinator,
|
||||||
VideoCallLifecycleBinding? lifecycleBinding,
|
VideoCallLifecycleBinding? lifecycleBinding,
|
||||||
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
|
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
|
||||||
|
Future<String?> Function()? resolveHwId,
|
||||||
}) : _videoCallRepository = videoCallRepository,
|
}) : _videoCallRepository = videoCallRepository,
|
||||||
_profileRepository = profileRepository,
|
_profileRepository = profileRepository,
|
||||||
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
|
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
|
||||||
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
|
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
|
||||||
_audioSession =
|
_audioSession =
|
||||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
|
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
|
||||||
|
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
|
||||||
|
_audioSession.onIosAudioSessionActivated = _restartIosCaptureIfMicOn;
|
||||||
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
|
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;
|
final VideoCallRepository _videoCallRepository;
|
||||||
@@ -44,9 +57,13 @@ class VideoCallManager {
|
|||||||
final VideoCallForegroundCoordinator _foreground;
|
final VideoCallForegroundCoordinator _foreground;
|
||||||
final VideoCallLifecycleBinding _lifecycleBinding;
|
final VideoCallLifecycleBinding _lifecycleBinding;
|
||||||
final VideoCallAudioSessionCoordinator _audioSession;
|
final VideoCallAudioSessionCoordinator _audioSession;
|
||||||
|
final Future<String?> Function() _resolveHwId;
|
||||||
|
|
||||||
static const lk.AudioCaptureOptions _micCaptureOptions =
|
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 _mutedMicPollInterval = Duration(milliseconds: 320);
|
||||||
static const Duration _mutedMicOverlayMinInterval = Duration(
|
static const Duration _mutedMicOverlayMinInterval = Duration(
|
||||||
milliseconds: 2600,
|
milliseconds: 2600,
|
||||||
@@ -65,6 +82,10 @@ class VideoCallManager {
|
|||||||
num? _lastMutedMicTotalEnergy;
|
num? _lastMutedMicTotalEnergy;
|
||||||
bool _liveKitDisposed = true;
|
bool _liveKitDisposed = true;
|
||||||
bool _callEndedNotified = true;
|
bool _callEndedNotified = true;
|
||||||
|
bool _forceEndRequested = false;
|
||||||
|
bool _sessionMayRequestToken = false;
|
||||||
|
String? _activeCallId;
|
||||||
|
final Map<String, DateTime> _leftConversationAt = {};
|
||||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||||
bool _callStageEntered = false;
|
bool _callStageEntered = false;
|
||||||
bool _joinInProgress = false;
|
bool _joinInProgress = false;
|
||||||
@@ -86,6 +107,16 @@ class VideoCallManager {
|
|||||||
String? get counterpartName => _snapshot.counterpartName;
|
String? get counterpartName => _snapshot.counterpartName;
|
||||||
String? get localParticipantName => _snapshot.localParticipantName;
|
String? get localParticipantName => _snapshot.localParticipantName;
|
||||||
|
|
||||||
|
bool get callEndedNotified => _callEndedNotified;
|
||||||
|
|
||||||
|
String? get activeCallId => _activeCallId;
|
||||||
|
|
||||||
|
void rememberCallId(String? callId) {
|
||||||
|
final id = callId?.trim();
|
||||||
|
if (id == null || id.isEmpty) return;
|
||||||
|
_activeCallId = id;
|
||||||
|
}
|
||||||
|
|
||||||
bool get isActiveCall =>
|
bool get isActiveCall =>
|
||||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||||
_callStageEntered &&
|
_callStageEntered &&
|
||||||
@@ -164,13 +195,29 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startCall({
|
/// Lets the next [startCall] ask the server for a token.
|
||||||
|
///
|
||||||
|
/// Only the call button and an accepted incoming call set this. A screen
|
||||||
|
/// that reappears after hang-up must not request a token: that request is
|
||||||
|
/// what rings the other person again.
|
||||||
|
void permitParticipantTokenRequest() {
|
||||||
|
_sessionMayRequestToken = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> startCall({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
bool micEnabled = false,
|
bool micEnabled = false,
|
||||||
bool videoEnabled = false,
|
bool videoEnabled = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
final allowed = mayRequestParticipantToken(
|
||||||
|
sessionPermitted: _sessionMayRequestToken,
|
||||||
|
leftAt: _leftConversationAt[conversationUuid],
|
||||||
|
now: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (!allowed) return false;
|
||||||
|
|
||||||
if (_room != null &&
|
if (_room != null &&
|
||||||
!_liveKitDisposed &&
|
!_liveKitDisposed &&
|
||||||
_snapshot.conversationUuid == conversationUuid) {
|
_snapshot.conversationUuid == conversationUuid) {
|
||||||
@@ -185,7 +232,7 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
_ensureLifecycleAttached();
|
_ensureLifecycleAttached();
|
||||||
unawaited(_startCallAudioSession());
|
unawaited(_startCallAudioSession());
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (!_callStageEntered &&
|
if (!_callStageEntered &&
|
||||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||||
@@ -199,14 +246,32 @@ class VideoCallManager {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_ensureLifecycleAttached();
|
_ensureLifecycleAttached();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await endCall();
|
final previousUuid = _snapshot.conversationUuid;
|
||||||
|
final hasLiveRoom = _room != null && !_liveKitDisposed;
|
||||||
|
if (shouldPreserveCallKitWhenStartingCall(
|
||||||
|
previousConversationUuid: previousUuid,
|
||||||
|
nextConversationUuid: conversationUuid,
|
||||||
|
)) {
|
||||||
|
// The accepted CallKit call owns the iOS microphone. A full endCall
|
||||||
|
// here deactivates that session before the mic track exists.
|
||||||
|
if (hasLiveRoom) {
|
||||||
|
_disposeRoomEphemerals();
|
||||||
|
await _disconnectLiveKitWithTimeout();
|
||||||
|
_liveKitDisposed = false;
|
||||||
|
_callStageEntered = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await endCall();
|
||||||
|
}
|
||||||
|
_sessionMayRequestToken = true;
|
||||||
|
|
||||||
_resetSessionFlagsForNewRoom();
|
_resetSessionFlagsForNewRoom();
|
||||||
_callEndedNotified = false;
|
_callEndedNotified = false;
|
||||||
|
_forceEndRequested = false;
|
||||||
_liveKitDisposed = false;
|
_liveKitDisposed = false;
|
||||||
_callStageEntered = false;
|
_callStageEntered = false;
|
||||||
_room = lk.Room(
|
_room = lk.Room(
|
||||||
@@ -240,9 +305,10 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await _prepareLiveKitConnection();
|
await _prepareLiveKitConnection();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onCallScreenVisible({
|
Future<bool> onCallScreenVisible({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
@@ -252,7 +318,7 @@ class VideoCallManager {
|
|||||||
if (_snapshot.conversationUuid != conversationUuid ||
|
if (_snapshot.conversationUuid != conversationUuid ||
|
||||||
_room == null ||
|
_room == null ||
|
||||||
_liveKitDisposed) {
|
_liveKitDisposed) {
|
||||||
await startCall(
|
return startCall(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: counterpartName,
|
counterpartName: counterpartName,
|
||||||
localParticipantName: localParticipantName,
|
localParticipantName: localParticipantName,
|
||||||
@@ -271,6 +337,7 @@ class VideoCallManager {
|
|||||||
unawaited(_ensureLocalParticipantNamePublished());
|
unawaited(_ensureLocalParticipantNamePublished());
|
||||||
_syncRemoteParticipantNameFromRoom();
|
_syncRemoteParticipantNameFromRoom();
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,32 +458,52 @@ class VideoCallManager {
|
|||||||
|
|
||||||
Future<void> endCall() async {
|
Future<void> endCall() async {
|
||||||
final uuid = _snapshot.conversationUuid;
|
final uuid = _snapshot.conversationUuid;
|
||||||
_disposeRoomEphemerals();
|
final shouldNotifyBackend = shouldNotifyCallEnded(
|
||||||
await _disconnectLiveKitOnly();
|
alreadyNotified: _callEndedNotified,
|
||||||
if (!_callEndedNotified && uuid.isNotEmpty) {
|
hasConversation: uuid.isNotEmpty,
|
||||||
|
);
|
||||||
|
if (uuid.isNotEmpty) {
|
||||||
_callEndedNotified = true;
|
_callEndedNotified = true;
|
||||||
|
_leftConversationAt[uuid] = DateTime.now();
|
||||||
|
}
|
||||||
|
_sessionMayRequestToken = false;
|
||||||
|
_disposeRoomEphemerals();
|
||||||
|
await _disconnectLiveKitWithTimeout();
|
||||||
|
if (shouldNotifyBackend) {
|
||||||
try {
|
try {
|
||||||
await _videoCallRepository.notifyCallEnded(uuid);
|
await _videoCallRepository.notifyCallEnded(
|
||||||
|
uuid,
|
||||||
|
callId: backendVideoCallId(
|
||||||
|
callId: _activeCallId,
|
||||||
|
conversationUuid: uuid,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _foreground.stopOngoingCall();
|
await _foreground.stopOngoingCall();
|
||||||
await _stopCallAudioSession();
|
await _stopCallAudioSession();
|
||||||
|
await _dismissIosCallKitAfterHangup(uuid);
|
||||||
_lifecycleBinding.detach();
|
_lifecycleBinding.detach();
|
||||||
_callStageEntered = false;
|
_callStageEntered = false;
|
||||||
_videoPausedForBackground = false;
|
_videoPausedForBackground = false;
|
||||||
_liveKitDisposed = true;
|
_liveKitDisposed = true;
|
||||||
_myProfileNameFuture = null;
|
_myProfileNameFuture = null;
|
||||||
|
_activeCallId = null;
|
||||||
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifies backend that this device accepted an incoming call so ringing
|
/// Notifies backend that this device accepted an incoming call so ringing
|
||||||
/// stops on the user's other devices.
|
/// stops on the user's other devices.
|
||||||
Future<void> notifyIncomingCallAccepted(String conversationUuid) async {
|
Future<void> notifyIncomingCallAccepted(
|
||||||
|
String conversationUuid, {
|
||||||
|
String? callId,
|
||||||
|
}) async {
|
||||||
final uuid = conversationUuid.trim();
|
final uuid = conversationUuid.trim();
|
||||||
if (uuid.isEmpty) return;
|
if (uuid.isEmpty) return;
|
||||||
|
rememberCallId(callId);
|
||||||
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
|
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
|
||||||
|
|
||||||
final hwId = (await DeviceHwIdProvider.current())?.trim();
|
final hwId = (await _resolveHwId())?.trim();
|
||||||
if (hwId == null || hwId.length < 8) return;
|
if (hwId == null || hwId.length < 8) return;
|
||||||
|
|
||||||
_callAcceptNotifiedConversationUuids.add(uuid);
|
_callAcceptNotifiedConversationUuids.add(uuid);
|
||||||
@@ -424,29 +511,43 @@ class VideoCallManager {
|
|||||||
await _videoCallRepository.notifyCallAccepted(
|
await _videoCallRepository.notifyCallAccepted(
|
||||||
conversationUuid: uuid,
|
conversationUuid: uuid,
|
||||||
hwId: hwId,
|
hwId: hwId,
|
||||||
|
callId: _activeCallId ?? callId,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_callAcceptNotifiedConversationUuids.remove(uuid);
|
_callAcceptNotifiedConversationUuids.remove(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decline from incoming UI before joining the LiveKit room.
|
/// Drops local call state after the system decline button.
|
||||||
|
///
|
||||||
|
/// `POST /video-call/decline` is sent from iOS and Android at the tap, so
|
||||||
|
/// other devices stop ringing even when this isolate is not running.
|
||||||
Future<void> declineIncomingCall(String conversationUuid) async {
|
Future<void> declineIncomingCall(String conversationUuid) async {
|
||||||
final uuid = conversationUuid.trim();
|
final uuid = conversationUuid.trim();
|
||||||
if (uuid.isEmpty) {
|
await _cleanupLocalCallAfterDecline(
|
||||||
await endCall();
|
conversationUuid: uuid.isEmpty ? null : uuid,
|
||||||
return;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_snapshot.conversationUuid == uuid) {
|
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
|
||||||
await endCall();
|
final uuid = conversationUuid?.trim();
|
||||||
return;
|
if (uuid != null && uuid.isNotEmpty) {
|
||||||
|
_leftConversationAt[uuid] = DateTime.now();
|
||||||
}
|
}
|
||||||
|
_callEndedNotified = true;
|
||||||
await endCall();
|
_sessionMayRequestToken = false;
|
||||||
try {
|
_disposeRoomEphemerals();
|
||||||
await _videoCallRepository.notifyCallEnded(uuid);
|
await _disconnectLiveKitWithTimeout();
|
||||||
} catch (_) {}
|
await _foreground.stopOngoingCall();
|
||||||
|
await _stopCallAudioSession();
|
||||||
|
await _dismissIosCallKitAfterHangup(uuid);
|
||||||
|
_lifecycleBinding.detach();
|
||||||
|
_callStageEntered = false;
|
||||||
|
_videoPausedForBackground = false;
|
||||||
|
_liveKitDisposed = true;
|
||||||
|
_myProfileNameFuture = null;
|
||||||
|
_activeCallId = null;
|
||||||
|
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [currentUiEnabled] reflects what the user sees on the mic control
|
/// [currentUiEnabled] reflects what the user sees on the mic control
|
||||||
@@ -477,6 +578,10 @@ class VideoCallManager {
|
|||||||
next,
|
next,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
);
|
);
|
||||||
|
if (next) {
|
||||||
|
await _bindIosCallKitAudioEngine();
|
||||||
|
await _audioSession.restartCapture();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -674,6 +779,8 @@ class VideoCallManager {
|
|||||||
true,
|
true,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
);
|
);
|
||||||
|
await _bindIosCallKitAudioEngine();
|
||||||
|
await _audioSession.restartCapture();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _resumeVideoAfterForeground();
|
await _resumeVideoAfterForeground();
|
||||||
@@ -814,6 +921,12 @@ class VideoCallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _requestForceEnd() {
|
||||||
|
if (_forceEndRequested || _liveKitDisposed) return;
|
||||||
|
_forceEndRequested = true;
|
||||||
|
_emitSnapshot(_snapshot.copyWith(forceEndCall: true));
|
||||||
|
}
|
||||||
|
|
||||||
void _attachRoomEventListeners() {
|
void _attachRoomEventListeners() {
|
||||||
final room = _room;
|
final room = _room;
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
@@ -825,11 +938,35 @@ class VideoCallManager {
|
|||||||
if (_snapshot.linkIssue.needsManualReconnect) return;
|
if (_snapshot.linkIssue.needsManualReconnect) return;
|
||||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
|
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
|
||||||
})
|
})
|
||||||
..on<lk.RoomDisconnectedEvent>((_) {
|
..on<lk.RoomDisconnectedEvent>((event) {
|
||||||
if (_liveKitDisposed) return;
|
if (_liveKitDisposed) return;
|
||||||
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
||||||
|
if (shouldForceEndCallAfterRoomDisconnect(
|
||||||
|
callStageEntered: _callStageEntered,
|
||||||
|
sessionConnected: true,
|
||||||
|
reason: event.reason,
|
||||||
|
)) {
|
||||||
|
_requestForceEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
||||||
})
|
})
|
||||||
|
..on<lk.ParticipantDisconnectedEvent>((event) {
|
||||||
|
final room = _room;
|
||||||
|
if (room == null) return;
|
||||||
|
final departedIdentity = event.participant.identity;
|
||||||
|
if (!shouldForceEndCallAfterPeerLeft(
|
||||||
|
callStageEntered: _callStageEntered,
|
||||||
|
sessionConnected:
|
||||||
|
_snapshot.connectPhase == VideoCallConnectPhase.connected,
|
||||||
|
remoteIdentities: room.remoteParticipants.values
|
||||||
|
.map((participant) => participant.identity)
|
||||||
|
.where((identity) => identity != departedIdentity),
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_requestForceEnd();
|
||||||
|
})
|
||||||
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
||||||
if (e.participant != room.localParticipant) return;
|
if (e.participant != room.localParticipant) return;
|
||||||
_syncPoorConnectionOverlay(e.connectionQuality);
|
_syncPoorConnectionOverlay(e.connectionQuality);
|
||||||
@@ -922,13 +1059,21 @@ class VideoCallManager {
|
|||||||
Future<void> _prepareLiveKitConnection() async {
|
Future<void> _prepareLiveKitConnection() async {
|
||||||
final room = _room;
|
final room = _room;
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
|
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||||
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
||||||
try {
|
try {
|
||||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
final tokenResponse = await _videoCallRepository
|
||||||
_snapshot.conversationUuid,
|
.getParticipantToken(
|
||||||
);
|
_snapshot.conversationUuid,
|
||||||
|
callId: backendVideoCallId(
|
||||||
|
callId: _activeCallId,
|
||||||
|
conversationUuid: _snapshot.conversationUuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
if (_liveKitDisposed) return;
|
if (_liveKitDisposed) return;
|
||||||
|
|
||||||
|
rememberCallId(tokenResponse.callId);
|
||||||
_preparedWsUrl = tokenResponse.wsUrl;
|
_preparedWsUrl = tokenResponse.wsUrl;
|
||||||
_preparedToken = tokenResponse.token;
|
_preparedToken = tokenResponse.token;
|
||||||
|
|
||||||
@@ -970,17 +1115,25 @@ class VideoCallManager {
|
|||||||
var wsUrl = _preparedWsUrl;
|
var wsUrl = _preparedWsUrl;
|
||||||
var token = _preparedToken;
|
var token = _preparedToken;
|
||||||
if (wsUrl == null || token == null) {
|
if (wsUrl == null || token == null) {
|
||||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||||
_snapshot.conversationUuid,
|
final tokenResponse = await _videoCallRepository
|
||||||
);
|
.getParticipantToken(
|
||||||
|
_snapshot.conversationUuid,
|
||||||
|
callId: backendVideoCallId(
|
||||||
|
callId: _activeCallId,
|
||||||
|
conversationUuid: _snapshot.conversationUuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
if (_liveKitDisposed) return;
|
if (_liveKitDisposed) return;
|
||||||
|
rememberCallId(tokenResponse.callId);
|
||||||
wsUrl = tokenResponse.wsUrl;
|
wsUrl = tokenResponse.wsUrl;
|
||||||
token = tokenResponse.token;
|
token = tokenResponse.token;
|
||||||
_preparedWsUrl = wsUrl;
|
_preparedWsUrl = wsUrl;
|
||||||
_preparedToken = token;
|
_preparedToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
await room.connect(wsUrl, token);
|
await room.connect(wsUrl, token).timeout(const Duration(seconds: 15));
|
||||||
if (_liveKitDisposed) return;
|
if (_liveKitDisposed) return;
|
||||||
|
|
||||||
await _ensureLocalParticipantNamePublished();
|
await _ensureLocalParticipantNamePublished();
|
||||||
@@ -991,6 +1144,13 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _disconnectLiveKitWithTimeout() {
|
||||||
|
return _disconnectLiveKitOnly().timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _disconnectLiveKitOnly() async {
|
Future<void> _disconnectLiveKitOnly() async {
|
||||||
if (_room == null) return;
|
if (_room == null) return;
|
||||||
await _stopCallAudioSession();
|
await _stopCallAudioSession();
|
||||||
@@ -1034,6 +1194,11 @@ class VideoCallManager {
|
|||||||
if (status.isGranted) return true;
|
if (status.isGranted) return true;
|
||||||
status = await Permission.microphone.request();
|
status = await Permission.microphone.request();
|
||||||
if (status.isGranted) return true;
|
if (status.isGranted) return true;
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
// CallKit already opened a recording session. A stale permission
|
||||||
|
// status must not block publishing the incoming microphone.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
final ctx2 = _navigatorContext;
|
final ctx2 = _navigatorContext;
|
||||||
if (ctx2 == null || !ctx2.mounted) return false;
|
if (ctx2 == null || !ctx2.mounted) return false;
|
||||||
final s = S.of(ctx2);
|
final s = S.of(ctx2);
|
||||||
@@ -1048,6 +1213,14 @@ class VideoCallManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _tokenRequestAllowed(String conversationUuid) {
|
||||||
|
return mayRequestParticipantToken(
|
||||||
|
sessionPermitted: _sessionMayRequestToken,
|
||||||
|
leftAt: _leftConversationAt[conversationUuid],
|
||||||
|
now: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool get _canHandleCallAudio =>
|
bool get _canHandleCallAudio =>
|
||||||
_callStageEntered &&
|
_callStageEntered &&
|
||||||
!_liveKitDisposed &&
|
!_liveKitDisposed &&
|
||||||
@@ -1085,10 +1258,14 @@ class VideoCallManager {
|
|||||||
|
|
||||||
if (micOn) {
|
if (micOn) {
|
||||||
try {
|
try {
|
||||||
await lp?.setMicrophoneEnabled(
|
await lp
|
||||||
true,
|
?.setMicrophoneEnabled(
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
true,
|
||||||
);
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
await _bindIosCallKitAudioEngine();
|
||||||
|
await _audioSession.restartCapture();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
effectiveMic = false;
|
effectiveMic = false;
|
||||||
}
|
}
|
||||||
@@ -1129,6 +1306,26 @@ class VideoCallManager {
|
|||||||
_lastMutedMicTotalEnergy = null;
|
_lastMutedMicTotalEnergy = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _bindIosCallKitAudioEngine() async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
await _audioSession.startCallKitCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CallKit activates the session on accept and again when the call is
|
||||||
|
/// marked connected. The second activation can stop AudioEngine after the
|
||||||
|
/// microphone track is already published, so recording has to start again.
|
||||||
|
Future<void> _restartIosCaptureIfMicOn() async {
|
||||||
|
if (!_iosMicCaptureReady) return;
|
||||||
|
// The plugin posts an audio-interruption ended notice in the same
|
||||||
|
// didActivate. Starting immediately loses to that notice.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
if (!_iosMicCaptureReady) return;
|
||||||
|
await _audioSession.startCallKitCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _iosMicCaptureReady =>
|
||||||
|
Platform.isIOS && _snapshot.micEnabled && _callStageEntered;
|
||||||
|
|
||||||
Future<void> _startCallAudioSession() async {
|
Future<void> _startCallAudioSession() async {
|
||||||
if (!_canHandleCallAudio) return;
|
if (!_canHandleCallAudio) return;
|
||||||
try {
|
try {
|
||||||
@@ -1141,4 +1338,31 @@ class VideoCallManager {
|
|||||||
await _audioSession.stop();
|
await _audioSession.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Incoming iOS keeps CallKit up for the whole call so the microphone
|
||||||
|
/// session stays. Hang-up has to close that CallKit call.
|
||||||
|
///
|
||||||
|
/// An empty id used to match every active call, including the one the
|
||||||
|
/// callee just answered, and CallKit then deactivated the microphone.
|
||||||
|
Future<void> _dismissIosCallKitAfterHangup(String? conversationUuid) async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
|
||||||
|
if (uuid.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
if (activeCalls.isEmpty) return;
|
||||||
|
for (final call in activeCalls) {
|
||||||
|
final handle = call.handle?.trim().toLowerCase();
|
||||||
|
final extraUuid =
|
||||||
|
call.extra?['conversationUuid']?.toString().trim().toLowerCase() ??
|
||||||
|
call.extra?['conversation_uuid']?.toString().trim().toLowerCase();
|
||||||
|
final matches = handle == uuid || extraUuid == uuid;
|
||||||
|
if (!matches) continue;
|
||||||
|
VideoCallRemoteEndRegistry.mark(call.id);
|
||||||
|
try {
|
||||||
|
await FlutterCallkitIncoming.endCall(call.id);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class VideoCallManagerSnapshot {
|
|||||||
required this.connectPhase,
|
required this.connectPhase,
|
||||||
required this.linkIssue,
|
required this.linkIssue,
|
||||||
this.counterpartPresentInLobby = false,
|
this.counterpartPresentInLobby = false,
|
||||||
|
this.forceEndCall = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String conversationUuid;
|
final String conversationUuid;
|
||||||
@@ -22,6 +23,7 @@ class VideoCallManagerSnapshot {
|
|||||||
final VideoCallConnectPhase connectPhase;
|
final VideoCallConnectPhase connectPhase;
|
||||||
final VideoCallLinkIssue linkIssue;
|
final VideoCallLinkIssue linkIssue;
|
||||||
final bool counterpartPresentInLobby;
|
final bool counterpartPresentInLobby;
|
||||||
|
final bool forceEndCall;
|
||||||
|
|
||||||
static const initial = VideoCallManagerSnapshot(
|
static const initial = VideoCallManagerSnapshot(
|
||||||
conversationUuid: '',
|
conversationUuid: '',
|
||||||
@@ -41,6 +43,7 @@ class VideoCallManagerSnapshot {
|
|||||||
VideoCallConnectPhase? connectPhase,
|
VideoCallConnectPhase? connectPhase,
|
||||||
VideoCallLinkIssue? linkIssue,
|
VideoCallLinkIssue? linkIssue,
|
||||||
bool? counterpartPresentInLobby,
|
bool? counterpartPresentInLobby,
|
||||||
|
bool? forceEndCall,
|
||||||
bool clearConnectError = false,
|
bool clearConnectError = false,
|
||||||
}) {
|
}) {
|
||||||
return VideoCallManagerSnapshot(
|
return VideoCallManagerSnapshot(
|
||||||
@@ -54,6 +57,7 @@ class VideoCallManagerSnapshot {
|
|||||||
linkIssue: linkIssue ?? this.linkIssue,
|
linkIssue: linkIssue ?? this.linkIssue,
|
||||||
counterpartPresentInLobby:
|
counterpartPresentInLobby:
|
||||||
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
|
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
|
||||||
|
forceEndCall: forceEndCall ?? this.forceEndCall,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
|
|||||||
required String token,
|
required String token,
|
||||||
required String roomName,
|
required String roomName,
|
||||||
required String wsUrl,
|
required String wsUrl,
|
||||||
|
@JsonKey(name: 'call_id') String? callId,
|
||||||
|
@JsonKey(name: 'call_status') String? callStatus,
|
||||||
|
@JsonKey(name: 'call_expires_at') String? callExpiresAt,
|
||||||
}) = _VideoCallTokenResponse;
|
}) = _VideoCallTokenResponse;
|
||||||
|
|
||||||
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
|
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
|
||||||
@@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
||||||
const factory VideoCallEndedResponse({required bool sent}) =
|
const factory VideoCallEndedResponse({
|
||||||
_VideoCallEndedResponse;
|
required bool sent,
|
||||||
|
@JsonKey(name: 'call_id') String? callId,
|
||||||
|
String? status,
|
||||||
|
}) = _VideoCallEndedResponse;
|
||||||
|
|
||||||
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
||||||
_$VideoCallEndedResponseFromJson(json);
|
_$VideoCallEndedResponseFromJson(json);
|
||||||
@@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
|
|||||||
const factory VideoCallAcceptResponse({
|
const factory VideoCallAcceptResponse({
|
||||||
required bool sent,
|
required bool sent,
|
||||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||||
|
@JsonKey(name: 'call_id') String? callId,
|
||||||
|
String? status,
|
||||||
|
@JsonKey(name: 'already_accepted') bool? alreadyAccepted,
|
||||||
}) = _VideoCallAcceptResponse;
|
}) = _VideoCallAcceptResponse;
|
||||||
|
|
||||||
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
|
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
|
||||||
_$VideoCallAcceptResponseFromJson(json);
|
_$VideoCallAcceptResponseFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse {
|
||||||
|
const factory VideoCallDeclineResponse({
|
||||||
|
required bool sent,
|
||||||
|
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||||
|
@JsonKey(name: 'call_id') String? callId,
|
||||||
|
String? status,
|
||||||
|
}) = _VideoCallDeclineResponse;
|
||||||
|
|
||||||
|
factory VideoCallDeclineResponse.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$VideoCallDeclineResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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/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_peer_departure.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_screen_wake_lock_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -30,6 +32,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||||
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
|
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
|
||||||
bool _callScreenRouteVisible = false;
|
bool _callScreenRouteVisible = false;
|
||||||
|
bool _handlingRemoteCallEnd = false;
|
||||||
|
bool _suppressDisposeSideEffects = false;
|
||||||
bool _autoEnterInFlight = false;
|
bool _autoEnterInFlight = false;
|
||||||
bool _counterpartPresentWhenLobbyReady = false;
|
bool _counterpartPresentWhenLobbyReady = false;
|
||||||
bool _sawEmptyLobbyWhenConnected = false;
|
bool _sawEmptyLobbyWhenConnected = false;
|
||||||
@@ -74,12 +78,25 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
|
|
||||||
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
|
if (snapshot.forceEndCall) {
|
||||||
|
unawaited(handleRemoteCallEnded());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isCallSessionInactive(snapshot)) {
|
if (_isCallSessionInactive(snapshot)) {
|
||||||
unawaited(_systemPipCoordinator.disarm());
|
unawaited(_systemPipCoordinator.disarm());
|
||||||
if (_callScreenRouteVisible) {
|
if (isTransientIdleSnapshotWhileCallScreenVisible(
|
||||||
|
callScreenVisible: _callScreenRouteVisible,
|
||||||
|
conversationUuid: snapshot.conversationUuid,
|
||||||
|
needsManualReconnect: snapshot.linkIssue.needsManualReconnect,
|
||||||
|
connectError: snapshot.connectError,
|
||||||
|
)) {
|
||||||
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
|
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_callScreenRouteVisible) {
|
||||||
|
emit(_sessionStateFromSnapshot(snapshot));
|
||||||
|
return;
|
||||||
|
}
|
||||||
_resetLobbyReadyTracking();
|
_resetLobbyReadyTracking();
|
||||||
emit(const VideoCallSessionState());
|
emit(const VideoCallSessionState());
|
||||||
unawaited(_restoreSystemUiAfterCall());
|
unawaited(_restoreSystemUiAfterCall());
|
||||||
@@ -164,7 +181,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onCallScreenVisible({
|
Future<bool> onCallScreenVisible({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
@@ -218,7 +235,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_applyVideoCallSystemUi());
|
||||||
});
|
});
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResumingActiveCall) {
|
if (isResumingActiveCall) {
|
||||||
@@ -239,7 +256,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
counterpartName: counterpartName,
|
counterpartName: counterpartName,
|
||||||
localParticipantName: localParticipantName,
|
localParticipantName: localParticipantName,
|
||||||
counterpartIsConsultant: counterpartIsConsultant ?? false,
|
counterpartIsConsultant: counterpartIsConsultant ?? false,
|
||||||
micEnabled: false,
|
micEnabled: isIncomingAccepted,
|
||||||
videoEnabled: false,
|
videoEnabled: false,
|
||||||
callStageEntered: false,
|
callStageEntered: false,
|
||||||
connectError: null,
|
connectError: null,
|
||||||
@@ -248,13 +265,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
entryMode: effectiveEntryMode,
|
entryMode: effectiveEntryMode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await _videoCallManager.startCall(
|
final started = await _videoCallManager.startCall(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: counterpartName,
|
counterpartName: counterpartName,
|
||||||
localParticipantName: localParticipantName,
|
localParticipantName: localParticipantName,
|
||||||
micEnabled: false,
|
micEnabled: isIncomingAccepted,
|
||||||
videoEnabled: false,
|
videoEnabled: false,
|
||||||
);
|
);
|
||||||
|
if (!started) {
|
||||||
|
if (!isClosed) emit(const VideoCallSessionState());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
emit(state.copyWith(entryMode: effectiveEntryMode));
|
emit(state.copyWith(entryMode: effectiveEntryMode));
|
||||||
if (isIncomingAccepted) {
|
if (isIncomingAccepted) {
|
||||||
await _tryAutoEnterCallStage();
|
await _tryAutoEnterCallStage();
|
||||||
@@ -266,6 +287,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_applyVideoCallSystemUi());
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ensureSystemPipArmedIfInCall() async {
|
Future<void> _ensureSystemPipArmedIfInCall() async {
|
||||||
@@ -349,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
_autoEnterInFlight = true;
|
_autoEnterInFlight = true;
|
||||||
try {
|
try {
|
||||||
await _requestMicBeforeIncomingEnter();
|
await _requestMicBeforeIncomingEnter();
|
||||||
await enterCallStage();
|
for (var attempt = 0; attempt < 3; attempt++) {
|
||||||
|
if (isClosed || state.callStageEntered) return;
|
||||||
|
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||||
|
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||||
|
await enterCallStage();
|
||||||
|
if (state.callStageEntered) return;
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: 350 * (attempt + 1)));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_autoEnterInFlight = false;
|
_autoEnterInFlight = false;
|
||||||
}
|
}
|
||||||
@@ -359,10 +388,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
Future<void> _requestMicBeforeIncomingEnter() async {
|
Future<void> _requestMicBeforeIncomingEnter() async {
|
||||||
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||||
|
|
||||||
final granted = await _requestMicPermissionForCall();
|
await _requestMicPermissionForCall();
|
||||||
if (!granted || isClosed) return;
|
if (isClosed) return;
|
||||||
|
if (state.isMicActiveForUi) return;
|
||||||
|
|
||||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
await _videoCallManager.toggleMic(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
@@ -370,15 +400,47 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
|
|
||||||
Future<bool> _requestMicPermissionForCall() async {
|
Future<bool> _requestMicPermissionForCall() async {
|
||||||
try {
|
try {
|
||||||
final status = await Permission.microphone.request();
|
final current = await Permission.microphone.status;
|
||||||
|
if (current.isGranted) return true;
|
||||||
|
// The incoming-call activity can keep this future pending when the app
|
||||||
|
// was already open. The call has to leave the loading screen anyway.
|
||||||
|
final status = await Permission.microphone.request().timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () => PermissionStatus.denied,
|
||||||
|
);
|
||||||
return status.isGranted;
|
return status.isGranted;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 ||
|
||||||
|
state.connectPhase == VideoCallConnectPhase.connecting;
|
||||||
|
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() {
|
void onCallScreenDisposed() {
|
||||||
_callScreenRouteVisible = false;
|
_callScreenRouteVisible = false;
|
||||||
|
if (_suppressDisposeSideEffects) return;
|
||||||
if (state.uiMode != VideoCallUiMode.fullscreen) {
|
if (state.uiMode != VideoCallUiMode.fullscreen) {
|
||||||
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
|
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
|
||||||
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
|
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
|
||||||
@@ -510,9 +572,20 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> endCall() async {
|
Future<void> endCall() async {
|
||||||
await _screenWakeLock.release();
|
_suppressDisposeSideEffects = true;
|
||||||
await _systemPipCoordinator.disarm();
|
try {
|
||||||
await _videoCallManager.endCall();
|
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() {
|
void expandLocal() {
|
||||||
|
|||||||
@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
unawaited(_initCounterpartMetadata());
|
unawaited(_initCounterpartMetadata());
|
||||||
unawaited(
|
unawaited(_openCallSession());
|
||||||
_cubit.onCallScreenVisible(
|
|
||||||
conversationUuid: widget.conversationUuid,
|
|
||||||
counterpartName: widget.counterpartName,
|
|
||||||
localParticipantName: widget.localParticipantName,
|
|
||||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
|
||||||
entryMode: widget.entryMode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _initCounterpartMetadata() async {
|
||||||
var isOnline = widget.counterpartIsOnline ?? false;
|
var isOnline = widget.counterpartIsOnline ?? false;
|
||||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ class VideoCallRepository {
|
|||||||
|
|
||||||
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
|
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
|
||||||
|
|
||||||
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
|
Future<VideoCallTokenResponse> getParticipantToken(
|
||||||
|
String conversationUuid, {
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
return videoCallService.getParticipantToken(
|
return videoCallService.getParticipantToken(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
|
callId: callId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,20 +22,46 @@ class VideoCallRepository {
|
|||||||
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
|
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
|
Future<VideoCallEndedResponse> notifyCallEnded(
|
||||||
|
String conversationUuid, {
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(),
|
||||||
|
};
|
||||||
return videoCallService.notifyCallEnded(
|
return videoCallService.notifyCallEnded(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
body: _emptyJsonBody,
|
body: body.isEmpty ? _emptyJsonBody : body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
required String hwId,
|
required String hwId,
|
||||||
|
String? callId,
|
||||||
}) {
|
}) {
|
||||||
return videoCallService.notifyCallAccepted(
|
return videoCallService.notifyCallAccepted(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
body: <String, dynamic>{'hw_id': hwId},
|
body: <String, dynamic>{
|
||||||
|
'hw_id': hwId,
|
||||||
|
if (callId != null && callId.trim().isNotEmpty)
|
||||||
|
'call_id': callId.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||||
|
required String conversationUuid,
|
||||||
|
required String hwId,
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
return videoCallService.notifyCallDeclined(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
body: <String, dynamic>{
|
||||||
|
'hw_id': hwId,
|
||||||
|
if (callId != null && callId.trim().isNotEmpty)
|
||||||
|
'call_id': callId.trim(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
|||||||
|
|
||||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||||
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
|
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
|
||||||
|
typedef IosAdmHandler = Future<void> Function();
|
||||||
|
|
||||||
/// Configures and owns the platform audio session for an active VoIP call.
|
/// Configures and owns the platform audio session for an active VoIP call.
|
||||||
class VideoCallAudioSessionCoordinator {
|
class VideoCallAudioSessionCoordinator {
|
||||||
@@ -12,18 +13,38 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||||
bool? isIos,
|
bool? isIos,
|
||||||
IosCallAudioSessionHandler? iosCallAudioSession,
|
IosCallAudioSessionHandler? iosCallAudioSession,
|
||||||
|
IosAdmHandler? stopLocalRecording,
|
||||||
|
IosAdmHandler? startLocalRecording,
|
||||||
}) : _audioSessionFactory =
|
}) : _audioSessionFactory =
|
||||||
audioSessionFactory ?? (() => AudioSession.instance),
|
audioSessionFactory ?? (() => AudioSession.instance),
|
||||||
_isIos = isIos ?? Platform.isIOS,
|
_isIos = isIos ?? Platform.isIOS,
|
||||||
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession;
|
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession,
|
||||||
|
_stopLocalRecording = stopLocalRecording ?? _invokeStopLocalRecording,
|
||||||
|
_startLocalRecording =
|
||||||
|
startLocalRecording ?? _invokeStartLocalRecording {
|
||||||
|
if (iosCallAudioSession == null && _isIos) {
|
||||||
|
_activationListener = this;
|
||||||
|
_iosChannel.setMethodCallHandler(_onIosAudioSessionCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static const MethodChannel _iosChannel = MethodChannel(
|
static const MethodChannel _iosChannel = MethodChannel(
|
||||||
'com.eatvioapp/call_audio_session',
|
'com.eatvioapp/call_audio_session',
|
||||||
);
|
);
|
||||||
|
static const MethodChannel _webrtcChannel = MethodChannel(
|
||||||
|
'FlutterWebRTC.Method',
|
||||||
|
);
|
||||||
|
|
||||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||||
final bool _isIos;
|
final bool _isIos;
|
||||||
final IosCallAudioSessionHandler _iosCallAudioSession;
|
final IosCallAudioSessionHandler _iosCallAudioSession;
|
||||||
|
final IosAdmHandler _stopLocalRecording;
|
||||||
|
final IosAdmHandler _startLocalRecording;
|
||||||
|
static VideoCallAudioSessionCoordinator? _activationListener;
|
||||||
|
|
||||||
|
/// Called when CallKit activates the audio session, including the second
|
||||||
|
/// activation from `setCallConnected`.
|
||||||
|
Future<void> Function()? onIosAudioSessionActivated;
|
||||||
|
|
||||||
static final AudioSessionConfiguration callConfiguration =
|
static final AudioSessionConfiguration callConfiguration =
|
||||||
AudioSessionConfiguration(
|
AudioSessionConfiguration(
|
||||||
@@ -31,7 +52,7 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
avAudioSessionCategoryOptions:
|
avAudioSessionCategoryOptions:
|
||||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
avAudioSessionMode: AVAudioSessionMode.videoChat,
|
||||||
avAudioSessionRouteSharingPolicy:
|
avAudioSessionRouteSharingPolicy:
|
||||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||||
@@ -54,6 +75,36 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
return _iosChannel.invokeMethod<void>(method);
|
return _iosChannel.invokeMethod<void>(method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> _onIosAudioSessionCall(MethodCall call) async {
|
||||||
|
if (call.method != 'audioSessionActivated') return;
|
||||||
|
await _activationListener?.onIosAudioSessionActivated?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _ensureWebRtcInitialized() async {
|
||||||
|
try {
|
||||||
|
await _webrtcChannel.invokeMethod<void>(
|
||||||
|
'initialize',
|
||||||
|
<String, dynamic>{'options': <String, dynamic>{}},
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _invokeStopLocalRecording() async {
|
||||||
|
await _ensureWebRtcInitialized();
|
||||||
|
await _webrtcChannel.invokeMethod<void>(
|
||||||
|
'stopLocalRecording',
|
||||||
|
<String, dynamic>{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _invokeStartLocalRecording() async {
|
||||||
|
await _ensureWebRtcInitialized();
|
||||||
|
await _webrtcChannel.invokeMethod<void>(
|
||||||
|
'startLocalRecording',
|
||||||
|
<String, dynamic>{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
if (_isIos) {
|
if (_isIos) {
|
||||||
await _iosCallAudioSession('activate');
|
await _iosCallAudioSession('activate');
|
||||||
@@ -66,7 +117,7 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
await session.configure(callConfiguration);
|
await session.configure(callConfiguration);
|
||||||
_configured = true;
|
_configured = true;
|
||||||
}
|
}
|
||||||
await session.setActive(true);
|
await session.setActive(true).timeout(const Duration(seconds: 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> reactivate() async {
|
Future<bool> reactivate() async {
|
||||||
@@ -88,9 +139,33 @@ 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 (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flutter-webrtc 1.2 uses AudioEngine ADM. `isAudioEnabled` does not start
|
||||||
|
/// that engine. `initAndStartRecording` has to run after the microphone
|
||||||
|
/// track exists. Do not stop first: that tears the capture graph down and
|
||||||
|
/// the published track stays silent.
|
||||||
|
Future<void> startCallKitCapture() async {
|
||||||
|
if (!_isIos) return;
|
||||||
|
try {
|
||||||
|
await _startLocalRecording();
|
||||||
|
_iosActive = true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
if (_isIos) {
|
if (_isIos) {
|
||||||
_iosActive = false;
|
_iosActive = false;
|
||||||
|
try {
|
||||||
|
await _stopLocalRecording();
|
||||||
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
await _iosCallAudioSession('deactivate');
|
await _iosCallAudioSession('deactivate');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
|
|||||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_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/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_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_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||||
@@ -34,6 +37,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
final Map<String, CallKitParams> _callKitParamsById = {};
|
final Map<String, CallKitParams> _callKitParamsById = {};
|
||||||
final Set<String> _acceptNavigationCompletedIds = {};
|
final Set<String> _acceptNavigationCompletedIds = {};
|
||||||
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
|
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
|
||||||
|
final Map<String, Future<void>> _acceptDeliveriesByConversation = {};
|
||||||
final Set<String> _suppressedEndCallKitIds = {};
|
final Set<String> _suppressedEndCallKitIds = {};
|
||||||
String? _activeCallKitId;
|
String? _activeCallKitId;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
@@ -75,6 +79,10 @@ class VideoCallCallKitCoordinator {
|
|||||||
_scheduleNativeVoipDiagnosticsDrain();
|
_scheduleNativeVoipDiagnosticsDrain();
|
||||||
case 'acceptedVoipCall':
|
case 'acceptedVoipCall':
|
||||||
await _handleNativeAcceptedVoipCall(call.arguments);
|
await _handleNativeAcceptedVoipCall(call.arguments);
|
||||||
|
case 'declinedVoipCall':
|
||||||
|
await _handleNativeDeclinedVoipCall(call.arguments);
|
||||||
|
case 'tombstoneCallId':
|
||||||
|
_handleNativeTombstoneCallId(call.arguments);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await _firebaseApi.registerPushToken();
|
await _firebaseApi.registerPushToken();
|
||||||
@@ -102,12 +110,19 @@ class VideoCallCallKitCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
|
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
|
||||||
if (call.method == 'onCallKitLaunchIntent') {
|
switch (call.method) {
|
||||||
log(
|
case 'onCallKitLaunchIntent':
|
||||||
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
|
log(
|
||||||
name: 'VideoCallCallKit',
|
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
|
||||||
);
|
name: 'VideoCallCallKit',
|
||||||
await _handleAndroidCallKitLaunchIntent(call.arguments);
|
);
|
||||||
|
await _handleAndroidCallKitLaunchIntent(call.arguments);
|
||||||
|
case 'onCallKitDecline':
|
||||||
|
log(
|
||||||
|
'VideoCallCallKitCoordinator: received onCallKitDecline from native',
|
||||||
|
name: 'VideoCallCallKit',
|
||||||
|
);
|
||||||
|
await _handleAndroidCallKitDecline(call.arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +236,21 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAndroidCallKitDecline(Object? arguments) async {
|
||||||
|
final data = _stringKeyedMap(arguments);
|
||||||
|
final extra = _stringKeyedMap(data['extra']);
|
||||||
|
final callKitId =
|
||||||
|
_parseString(data, const ['id', 'uuid']) ??
|
||||||
|
_parseString(extra, const ['conversationUuid', 'conversation_uuid']);
|
||||||
|
if (callKitId == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _androidCallPushChannel.invokeMethod<Object?>('drainPendingCallKitDecline');
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
await _handleDecline(callKitId, eventExtra: extra);
|
||||||
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final observer = _androidResumeObserver;
|
final observer = _androidResumeObserver;
|
||||||
if (observer != null) {
|
if (observer != null) {
|
||||||
@@ -362,7 +392,11 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _handleDecline(id);
|
await _handleDecline(
|
||||||
|
id,
|
||||||
|
eventExtra: _callKitEventParams(event)?.extra,
|
||||||
|
skipBackendNotify: Platform.isIOS && event is CallEventActionCallDecline,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEndedEvent(CallEvent event) async {
|
Future<void> _handleEndedEvent(CallEvent event) async {
|
||||||
@@ -374,7 +408,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _handleEnded(id);
|
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||||
@@ -488,8 +522,9 @@ class VideoCallCallKitCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final callId = _parseCallId(extra);
|
||||||
await _deliverIncomingAccept(
|
await _deliverIncomingAccept(
|
||||||
callKitId: conversationUuid,
|
callKitId: callId ?? conversationUuid,
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: _parseString(extra, const [
|
counterpartName: _parseString(extra, const [
|
||||||
'counterpartName',
|
'counterpartName',
|
||||||
@@ -515,6 +550,17 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleNativeTombstoneCallId(Object? arguments) {
|
||||||
|
final data = _stringKeyedMap(arguments);
|
||||||
|
final callId = _parseString(data, const ['id', 'call_id', 'callId']);
|
||||||
|
if (callId == null || callId.isEmpty) return;
|
||||||
|
VideoCallTombstoneRegistry.mark(callId);
|
||||||
|
log(
|
||||||
|
'VideoCallCallKitCoordinator: native tombstone call_id=$callId',
|
||||||
|
name: 'VideoCallCallKit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleNativeAcceptedVoipCall(Object? arguments) async {
|
Future<void> _handleNativeAcceptedVoipCall(Object? arguments) async {
|
||||||
if (!Platform.isIOS) return;
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
@@ -541,6 +587,9 @@ class VideoCallCallKitCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final callId = _parseCallId(extra) ?? callKitId;
|
||||||
|
_manager.rememberCallId(callId);
|
||||||
|
|
||||||
await _deliverIncomingAccept(
|
await _deliverIncomingAccept(
|
||||||
callKitId: callKitId,
|
callKitId: callKitId,
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
@@ -572,6 +621,39 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local cleanup after iOS CallKit decline. The backend request is already
|
||||||
|
/// sent from AppDelegate, including when this isolate is suspended.
|
||||||
|
Future<void> _handleNativeDeclinedVoipCall(Object? arguments) async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
|
final data = _stringKeyedMap(arguments);
|
||||||
|
final extra = _stringKeyedMap(data['extra']);
|
||||||
|
if (callKitExtraMarksEndedPush(Map<String, dynamic>.from(extra))) return;
|
||||||
|
|
||||||
|
final callKitId = _parseString(data, const ['id', 'uuid']);
|
||||||
|
if (callKitId != null &&
|
||||||
|
(_suppressedEndCallKitIds.contains(callKitId) ||
|
||||||
|
VideoCallRemoteEndRegistry.contains(callKitId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final conversationUuid = _parseConversationUuid(
|
||||||
|
extra,
|
||||||
|
handle: _parseString(data, const ['handle']),
|
||||||
|
);
|
||||||
|
final callId = backendVideoCallId(
|
||||||
|
callId:
|
||||||
|
_parseCallId(extra) ??
|
||||||
|
(callKitId != null && callKitId.contains('-') ? callKitId : null),
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
'VideoCallCallKitCoordinator: native declinedVoipCall conversation=$conversationUuid callId=$callId',
|
||||||
|
name: 'VideoCallCallKit',
|
||||||
|
);
|
||||||
|
await _manager.declineIncomingCall(conversationUuid);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleAccept(String callKitId) async {
|
Future<void> _handleAccept(String callKitId) async {
|
||||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||||
|
|
||||||
@@ -653,6 +735,13 @@ class VideoCallCallKitCoordinator {
|
|||||||
name: 'VideoCallCallKit',
|
name: 'VideoCallCallKit',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final conversationKey = conversationUuid.trim().toLowerCase();
|
||||||
|
final conversationDelivery = _acceptDeliveriesByConversation[conversationKey];
|
||||||
|
if (conversationDelivery != null) {
|
||||||
|
await conversationDelivery;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_rememberPendingIncomingAccept(
|
_rememberPendingIncomingAccept(
|
||||||
callKitId: callKitId,
|
callKitId: callKitId,
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
@@ -679,12 +768,16 @@ class VideoCallCallKitCoordinator {
|
|||||||
counterpartIsConsultant: counterpartIsConsultant,
|
counterpartIsConsultant: counterpartIsConsultant,
|
||||||
);
|
);
|
||||||
_acceptDeliveriesInFlight[callKitId] = delivery;
|
_acceptDeliveriesInFlight[callKitId] = delivery;
|
||||||
|
_acceptDeliveriesByConversation[conversationKey] = delivery;
|
||||||
try {
|
try {
|
||||||
await delivery;
|
await delivery;
|
||||||
} finally {
|
} finally {
|
||||||
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
|
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
|
||||||
_acceptDeliveriesInFlight.remove(callKitId);
|
_acceptDeliveriesInFlight.remove(callKitId);
|
||||||
}
|
}
|
||||||
|
if (identical(_acceptDeliveriesByConversation[conversationKey], delivery)) {
|
||||||
|
_acceptDeliveriesByConversation.remove(conversationKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,10 +791,26 @@ class VideoCallCallKitCoordinator {
|
|||||||
}) async {
|
}) async {
|
||||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||||
|
|
||||||
_suppressedEndCallKitIds.add(callKitId);
|
final params = _paramsForCallId(callKitId);
|
||||||
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
|
final callId =
|
||||||
|
_parseCallId(params?.extra ?? const <String, Object?>{}) ??
|
||||||
|
(callKitId.contains('-') ? callKitId : null);
|
||||||
|
if (callId != null) {
|
||||||
|
_manager.rememberCallId(callId);
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
_manager.notifyIncomingCallAccepted(conversationUuid, callId: callId),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android accept starts an ongoing-call service that holds the mic.
|
||||||
|
// Joining LiveKit while that service is still up never leaves the
|
||||||
|
// loading screen when the app was already in the foreground.
|
||||||
|
_suppressCallEnd(callKitId);
|
||||||
|
_suppressCallEnd(conversationUuid);
|
||||||
|
if (callId != null) _suppressCallEnd(callId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await _finishCallKitAfterAccept(callKitId);
|
||||||
await _waitForAcceptNavigationReady();
|
await _waitForAcceptNavigationReady();
|
||||||
|
|
||||||
final navigated = await _openConnectingScreenAfterAccept(
|
final navigated = await _openConnectingScreenAfterAccept(
|
||||||
@@ -737,8 +846,6 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
unawaited(_drainPendingIncomingAcceptWithRetries());
|
unawaited(_drainPendingIncomingAcceptWithRetries());
|
||||||
}
|
}
|
||||||
|
|
||||||
await FlutterCallkitIncoming.endCall(callKitId);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log(
|
log(
|
||||||
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
|
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
|
||||||
@@ -746,12 +853,22 @@ class VideoCallCallKitCoordinator {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
unawaited(
|
// Android ends CallKit right after accept, so the plugin ENDED event
|
||||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
// must be ignored briefly. iOS keeps CallKit for the whole call: a
|
||||||
_suppressedEndCallKitIds.remove(callKitId);
|
// later ENDED is a real hang-up and must not be swallowed.
|
||||||
_acceptNavigationCompletedIds.remove(callKitId);
|
if (Platform.isAndroid) {
|
||||||
}),
|
final suppressedIds = <String>{
|
||||||
);
|
callKitId,
|
||||||
|
conversationUuid,
|
||||||
|
?callId,
|
||||||
|
};
|
||||||
|
unawaited(
|
||||||
|
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||||
|
_suppressedEndCallKitIds.removeAll(suppressedIds);
|
||||||
|
_acceptNavigationCompletedIds.remove(callKitId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +900,12 @@ class VideoCallCallKitCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_suppressCallEnd(pending.callKitId);
|
||||||
|
_suppressCallEnd(pending.conversationUuid);
|
||||||
|
try {
|
||||||
|
await _finishCallKitAfterAccept(pending.callKitId);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
await _waitForAcceptNavigationReady();
|
await _waitForAcceptNavigationReady();
|
||||||
await _settleUiAfterResume();
|
await _settleUiAfterResume();
|
||||||
|
|
||||||
@@ -805,9 +928,44 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
try {
|
}
|
||||||
await FlutterCallkitIncoming.endCall(pending.callKitId);
|
|
||||||
} catch (_) {}
|
/// After answer: Android dismisses the incoming notification. iOS keeps the
|
||||||
|
/// CallKit call so the audio session stays, and marks it connected so the
|
||||||
|
/// plugin stops treating every later `endCall` as the last PushKit report.
|
||||||
|
Future<void> _finishCallKitAfterAccept(String callKitId) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await FlutterCallkitIncoming.setCallConnected(callKitId);
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_suppressCallEnd(callKitId);
|
||||||
|
await FlutterCallkitIncoming.endCall(callKitId);
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
await _waitUntilAndroidCallKitReleased();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telecom keeps the microphone while the incoming connection is active.
|
||||||
|
/// Joining LiveKit before it is gone never leaves the loading screen when
|
||||||
|
/// the app was already in the foreground.
|
||||||
|
Future<void> _waitUntilAndroidCallKitReleased() async {
|
||||||
|
final deadline = DateTime.now().add(const Duration(milliseconds: 1500));
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
try {
|
||||||
|
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
if (calls.isEmpty) return;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _suppressCallEnd(String id) {
|
||||||
|
final trimmed = id.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
_suppressedEndCallKitIds.add(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _settleUiAfterResume() async {
|
Future<void> _settleUiAfterResume() async {
|
||||||
@@ -842,6 +1000,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
bool? counterpartIsConsultant,
|
bool? counterpartIsConsultant,
|
||||||
}) async {
|
}) async {
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
|
_manager.permitParticipantTokenRequest();
|
||||||
|
|
||||||
return VideoCallNotificationNavigator.openIncomingCall(
|
return VideoCallNotificationNavigator.openIncomingCall(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
@@ -853,7 +1012,11 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleDecline(String callKitId) async {
|
Future<void> _handleDecline(
|
||||||
|
String callKitId, {
|
||||||
|
Map<String, dynamic>? eventExtra,
|
||||||
|
bool skipBackendNotify = false,
|
||||||
|
}) async {
|
||||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||||
log(
|
log(
|
||||||
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
||||||
@@ -863,11 +1026,36 @@ class VideoCallCallKitCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final params = _paramsForCallId(callKitId);
|
final params = _paramsForCallId(callKitId);
|
||||||
final extra = params?.extra ?? <String, dynamic>{};
|
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
|
||||||
final conversationUuid = _parseConversationUuid(
|
final conversationUuid = _parseConversationUuid(
|
||||||
extra,
|
extra,
|
||||||
handle: params?.handle ?? callKitId,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipBackendNotify) {
|
||||||
|
// AppDelegate already POSTed /decline. This plugin event must not send
|
||||||
|
// /ended for the same tap.
|
||||||
|
_forgetCallKitId(callKitId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_suppressedEndCallKitIds.add(callKitId);
|
_suppressedEndCallKitIds.add(callKitId);
|
||||||
try {
|
try {
|
||||||
@@ -879,14 +1067,12 @@ class VideoCallCallKitCoordinator {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_callKitParamsById.remove(callKitId);
|
_forgetCallKitId(callKitId);
|
||||||
if (_activeCallKitId == callKitId) {
|
|
||||||
_activeCallKitId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_manager.isActiveCall ||
|
if (_manager.isActiveCall ||
|
||||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||||
await _cubit.endCall();
|
await _cubit.endCall();
|
||||||
|
VideoCallNotificationNavigator.closeCallRouteIfOpen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,7 +1082,10 @@ class VideoCallCallKitCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEnded(String callKitId) async {
|
Future<void> _handleEnded(
|
||||||
|
String callKitId, {
|
||||||
|
Map<String, dynamic>? eventExtra,
|
||||||
|
}) async {
|
||||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||||
log(
|
log(
|
||||||
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
||||||
@@ -905,16 +1094,53 @@ class VideoCallCallKitCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_callKitParamsById.remove(callKitId);
|
// iOS reports an ended VoIP push to CallKit and closes it right away.
|
||||||
if (_activeCallKitId == callKitId) {
|
// That close is marked and must be remembered before the decline (or the
|
||||||
_activeCallKitId = null;
|
// later timeout) event for the same id arrives with no marker.
|
||||||
|
final remoteEnd =
|
||||||
|
callKitDeclineIsEndedPush(
|
||||||
|
cachedExtra: _paramsForCallId(callKitId)?.extra,
|
||||||
|
eventExtra: eventExtra,
|
||||||
|
) ||
|
||||||
|
VideoCallRemoteEndRegistry.contains(callKitId);
|
||||||
|
if (remoteEnd) {
|
||||||
|
VideoCallRemoteEndRegistry.mark(callKitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_forgetCallKitId(callKitId);
|
||||||
|
|
||||||
|
final inAppCall =
|
||||||
|
_manager.isActiveCall ||
|
||||||
|
_cubit.state.connectPhase != VideoCallConnectPhase.idle;
|
||||||
|
|
||||||
|
if (inAppCall) {
|
||||||
|
if (remoteEnd) {
|
||||||
|
await _cubit.handleRemoteCallEnded();
|
||||||
|
} else {
|
||||||
|
await _cubit.endCall();
|
||||||
|
VideoCallNotificationNavigator.closeCallRouteIfOpen();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native already ends a remote-ended ringing call. Calling endCall
|
||||||
|
// again here overwrites the plugin's stored payload, so the decline
|
||||||
|
// that follows would lose the marker.
|
||||||
|
if (remoteEnd) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await FlutterCallkitIncoming.endCall(callKitId);
|
await FlutterCallkitIncoming.endCall(callKitId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _forgetCallKitId(String callKitId) {
|
||||||
|
_callKitParamsById.remove(callKitId);
|
||||||
|
_acceptNavigationCompletedIds.remove(callKitId);
|
||||||
|
if (_activeCallKitId == callKitId) {
|
||||||
|
_activeCallKitId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String _parseConversationUuid(
|
static String _parseConversationUuid(
|
||||||
Map<String, Object?> extra, {
|
Map<String, Object?> extra, {
|
||||||
String? handle,
|
String? handle,
|
||||||
@@ -933,6 +1159,10 @@ class VideoCallCallKitCoordinator {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _parseCallId(Map<String, Object?> extra) {
|
||||||
|
return _parseString(extra, const ['call_id', 'callId']);
|
||||||
|
}
|
||||||
|
|
||||||
static String? _parseString(Map<String, Object?> map, List<String> keys) {
|
static String? _parseString(Map<String, Object?> map, List<String> keys) {
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
final value = map[key];
|
final value = map[key];
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ abstract final class VideoCallChatSignal {
|
|||||||
required ChatMessageModel message,
|
required ChatMessageModel message,
|
||||||
String? callerName,
|
String? callerName,
|
||||||
String? callerAvatarUrl,
|
String? callerAvatarUrl,
|
||||||
|
String? callId,
|
||||||
}) {
|
}) {
|
||||||
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
|
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
|
||||||
final conversationUuid = message.roomId?.trim();
|
final conversationUuid = message.roomId?.trim();
|
||||||
@@ -64,6 +65,7 @@ abstract final class VideoCallChatSignal {
|
|||||||
|
|
||||||
final resolvedName = callerName?.trim();
|
final resolvedName = callerName?.trim();
|
||||||
final avatar = callerAvatarUrl?.trim();
|
final avatar = callerAvatarUrl?.trim();
|
||||||
|
final serverCallId = callId?.trim();
|
||||||
return VideoCallPushPayload(
|
return VideoCallPushPayload(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: resolvedName != null && resolvedName.isNotEmpty
|
counterpartName: resolvedName != null && resolvedName.isNotEmpty
|
||||||
@@ -71,6 +73,9 @@ abstract final class VideoCallChatSignal {
|
|||||||
: callerNameFromBody(message.data),
|
: callerNameFromBody(message.data),
|
||||||
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
|
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
|
||||||
messageId: message.id > 0 ? message.id : null,
|
messageId: message.id > 0 ? message.id : null,
|
||||||
|
callId: serverCallId != null && serverCallId.isNotEmpty
|
||||||
|
? serverCallId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/// Cause of a `video_call_ended` push / Echo event.
|
||||||
|
///
|
||||||
|
/// This is not the session status. It tells the client how to close ringing
|
||||||
|
/// or an active call.
|
||||||
|
enum VideoCallEndReason {
|
||||||
|
accepted,
|
||||||
|
declined,
|
||||||
|
cancelled,
|
||||||
|
ended,
|
||||||
|
timeout;
|
||||||
|
|
||||||
|
static VideoCallEndReason? tryParse(Object? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_');
|
||||||
|
if (normalized.isEmpty) return null;
|
||||||
|
for (final value in VideoCallEndReason.values) {
|
||||||
|
if (value.name == normalized) return value;
|
||||||
|
}
|
||||||
|
if (normalized == 'hangup' || normalized == 'hang_up') {
|
||||||
|
return VideoCallEndReason.ended;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close ringing UI; do not tear down an already-accepted LiveKit call.
|
||||||
|
bool get dismissesRingingOnly =>
|
||||||
|
this == VideoCallEndReason.accepted ||
|
||||||
|
this == VideoCallEndReason.declined ||
|
||||||
|
this == VideoCallEndReason.cancelled ||
|
||||||
|
this == VideoCallEndReason.timeout;
|
||||||
|
|
||||||
|
/// Tear down an accepted in-progress call.
|
||||||
|
bool get endsActiveCall =>
|
||||||
|
this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout;
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
/// Dismisses incoming call UI after a remote `video_call_ended` push.
|
/// Dismisses incoming call UI after a remote `video_call_ended` push.
|
||||||
@@ -9,29 +16,176 @@ abstract final class VideoCallEndedPushHandler {
|
|||||||
'com.eatvioapp/video_call_push',
|
'com.eatvioapp/video_call_push',
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<void> dismissIncomingCallUi() async {
|
static Future<void> dismissIncomingCallUi({
|
||||||
|
String? conversationUuid,
|
||||||
|
String? callId,
|
||||||
|
VideoCallEndReason? reason,
|
||||||
|
}) async {
|
||||||
|
final normalizedCallId = callId?.trim();
|
||||||
|
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||||
|
VideoCallTombstoneRegistry.mark(normalizedCallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'VideoCallEndedPushHandler: dismiss '
|
||||||
|
'call_id=${normalizedCallId ?? '-'} '
|
||||||
|
'conversation=${conversationUuid ?? '-'} '
|
||||||
|
'reason=${reason?.name ?? '-'}',
|
||||||
|
name: 'VideoCallEndedPush',
|
||||||
|
);
|
||||||
|
|
||||||
|
final shouldEndActive = reason == null || reason.endsActiveCall;
|
||||||
|
if (shouldEndActive && reason != VideoCallEndReason.accepted) {
|
||||||
|
await endActiveCallIfMatches(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: normalizedCallId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
try {
|
try {
|
||||||
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
|
await _androidPushChannel.invokeMethod<void>(
|
||||||
|
'dismissIncomingCalls',
|
||||||
|
<String, String?>{
|
||||||
|
if (normalizedCallId != null && normalizedCallId.isNotEmpty)
|
||||||
|
'call_id': normalizedCallId,
|
||||||
|
if (conversationUuid != null && conversationUuid.trim().isNotEmpty)
|
||||||
|
'conversation_uuid': conversationUuid.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dismissViaCallKit();
|
await _dismissViaCallKit(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: normalizedCallId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _dismissViaCallKit() async {
|
/// Closes the CallKit / incoming UI without ending the LiveKit session.
|
||||||
|
/// Used when this client hangs up: CallKit stays up for the whole incoming
|
||||||
|
/// call on iOS and must be dismissed with the in-app hang-up.
|
||||||
|
static Future<void> dismissCallKitOnly({
|
||||||
|
String? conversationUuid,
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
return _dismissViaCallKit(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: callId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> endActiveCallIfMatches({
|
||||||
|
String? conversationUuid,
|
||||||
|
String? callId,
|
||||||
|
}) async {
|
||||||
|
final uuid = conversationUuid?.trim();
|
||||||
|
if (uuid == null || uuid.isEmpty) return;
|
||||||
|
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
|
||||||
|
|
||||||
|
final cubit = sl<VideoCallSessionCubit>();
|
||||||
|
if (cubit.state.conversationUuid != uuid) return;
|
||||||
|
// callId is reserved for ringing dismiss matching; active sessions are
|
||||||
|
// still keyed by conversation until the cubit stores call_id.
|
||||||
|
try {
|
||||||
|
await cubit.handleRemoteCallEnded();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool callMatches({
|
||||||
|
required CallKitParams call,
|
||||||
|
String? conversationUuid,
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
final normalizedCallId = callId?.trim().toLowerCase();
|
||||||
|
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||||
|
final id = call.id.trim().toLowerCase();
|
||||||
|
if (id.isNotEmpty && id == normalizedCallId) return true;
|
||||||
|
final extra = call.extra;
|
||||||
|
if (extra != null) {
|
||||||
|
for (final key in const ['call_id', 'callId']) {
|
||||||
|
final value = extra[key];
|
||||||
|
if (value is String &&
|
||||||
|
value.trim().toLowerCase() == normalizedCallId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A ring that already has its own call id must not be closed by a
|
||||||
|
// stale ended event for another call in the same chat. A legacy ring
|
||||||
|
// whose id is only the conversation still has to close.
|
||||||
|
if (_callHasDistinctCallId(call)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uuid = conversationUuid?.trim().toLowerCase();
|
||||||
|
if (uuid == null || uuid.isEmpty) return false;
|
||||||
|
return callMatchesConversation(call, uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this ring already carries a server call id. Conversation
|
||||||
|
/// fallback would close a newer call in the same chat.
|
||||||
|
static bool _callHasDistinctCallId(CallKitParams call) {
|
||||||
|
final extra = call.extra;
|
||||||
|
if (extra == null) return false;
|
||||||
|
for (final key in const ['call_id', 'callId']) {
|
||||||
|
final value = extra[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
String? callId,
|
||||||
|
}) async {
|
||||||
|
final uuid = conversationUuid?.trim();
|
||||||
|
final normalizedCallId = callId?.trim();
|
||||||
try {
|
try {
|
||||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
final hasTarget =
|
||||||
|
(normalizedCallId != null && normalizedCallId.isNotEmpty) ||
|
||||||
|
(uuid != null && uuid.isNotEmpty);
|
||||||
|
if (!hasTarget) {
|
||||||
|
for (final call in activeCalls) {
|
||||||
|
VideoCallRemoteEndRegistry.mark(call.id);
|
||||||
|
}
|
||||||
|
await FlutterCallkitIncoming.endAllCalls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (final call in activeCalls) {
|
for (final call in activeCalls) {
|
||||||
|
if (!callMatches(
|
||||||
|
call: call,
|
||||||
|
conversationUuid: uuid,
|
||||||
|
callId: normalizedCallId,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
VideoCallRemoteEndRegistry.mark(call.id);
|
||||||
try {
|
try {
|
||||||
await FlutterCallkitIncoming.endCall(call.id);
|
await FlutterCallkitIncoming.endCall(call.id);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
try {
|
|
||||||
await FlutterCallkitIncoming.endAllCalls();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,58 @@
|
|||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
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 {
|
class VideoCallEndedPushPayload {
|
||||||
const VideoCallEndedPushPayload._();
|
const VideoCallEndedPushPayload({
|
||||||
|
this.conversationUuid,
|
||||||
|
this.callId,
|
||||||
|
this.reason,
|
||||||
|
this.acceptedDeviceId,
|
||||||
|
this.acceptedClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? conversationUuid;
|
||||||
|
final String? callId;
|
||||||
|
final VideoCallEndReason? reason;
|
||||||
|
final String? acceptedDeviceId;
|
||||||
|
final String? acceptedClient;
|
||||||
|
|
||||||
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
||||||
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
||||||
return const VideoCallEndedPushPayload._();
|
|
||||||
|
final conversationUuid = _string(data, const [
|
||||||
|
'conversationUuid',
|
||||||
|
'conversation_uuid',
|
||||||
|
'uuid',
|
||||||
|
]);
|
||||||
|
final callId = _string(data, const ['call_id', 'callId']);
|
||||||
|
final acceptedDeviceId = _string(data, const [
|
||||||
|
'accepted_device_id',
|
||||||
|
'acceptedDeviceId',
|
||||||
|
]);
|
||||||
|
final acceptedClient = _string(data, const [
|
||||||
|
'accepted_client',
|
||||||
|
'acceptedClient',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return VideoCallEndedPushPayload(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: callId,
|
||||||
|
reason: VideoCallEndReason.tryParse(
|
||||||
|
data['reason'] ?? data['end_reason'] ?? data['endReason'],
|
||||||
|
),
|
||||||
|
acceptedDeviceId: acceptedDeviceId,
|
||||||
|
acceptedClient: acceptedClient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _string(Map<String, dynamic> data, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = data[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,17 @@ abstract final class VideoCallFcmHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
|
'VideoCallFcmHandler: dismiss incoming call UI, source=$source '
|
||||||
|
'call_id=${endedPayload.callId ?? '-'} '
|
||||||
|
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||||
name: 'VideoCallFcm',
|
name: 'VideoCallFcm',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: endedPayload.conversationUuid,
|
||||||
|
callId: endedPayload.callId,
|
||||||
|
reason: endedPayload.reason,
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log(
|
log(
|
||||||
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',
|
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',
|
||||||
|
|||||||
@@ -1,25 +1,93 @@
|
|||||||
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
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_kit_id.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.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 {
|
abstract final class VideoCallIncomingPushPresenter {
|
||||||
static const String _androidAccentColor = '#2787F5';
|
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
|
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||||
? payload.counterpartName!.trim()
|
? payload.counterpartName!.trim()
|
||||||
: 'Итвио';
|
: 'Итвио';
|
||||||
|
final ios = isIos ?? Platform.isIOS;
|
||||||
|
if (VideoCallTombstoneRegistry.contains(payload.callId)) {
|
||||||
|
log(
|
||||||
|
'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}',
|
||||||
|
name: 'VideoCallIncoming',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||||
|
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final callId = VideoCallKitId.resolve(
|
||||||
|
callId: payload.callId,
|
||||||
|
conversationUuid: payload.conversationUuid,
|
||||||
|
messageId: payload.messageId,
|
||||||
|
isIos: ios,
|
||||||
|
);
|
||||||
|
|
||||||
final callId = Platform.isIOS
|
if (ios) {
|
||||||
? VideoCallKitId.iosUuid(
|
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
|
||||||
conversationUuid: payload.conversationUuid,
|
final reserve = reserveIncomingCall ?? _reserveIncomingCall;
|
||||||
messageId: payload.messageId,
|
final wait = delay ?? Future<void>.delayed;
|
||||||
) ??
|
final polls = claimPollDelays ?? iosClaimPollDelays;
|
||||||
payload.conversationUuid
|
final started = DateTime.now();
|
||||||
: payload.conversationUuid;
|
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(
|
final params = CallKitParams(
|
||||||
id: callId,
|
id: callId,
|
||||||
@@ -31,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
duration: 45000,
|
duration: 45000,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
'conversationUuid': payload.conversationUuid,
|
'conversationUuid': payload.conversationUuid,
|
||||||
|
if (payload.callId != null) 'call_id': payload.callId,
|
||||||
if (payload.counterpartName != null)
|
if (payload.counterpartName != null)
|
||||||
'counterpartName': payload.counterpartName,
|
'counterpartName': payload.counterpartName,
|
||||||
if (payload.counterpartAvatarUrl != null)
|
if (payload.counterpartAvatarUrl != null)
|
||||||
@@ -39,8 +108,14 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
'localParticipantName': payload.localParticipantName,
|
'localParticipantName': payload.localParticipantName,
|
||||||
if (payload.counterpartIsConsultant != null)
|
if (payload.counterpartIsConsultant != null)
|
||||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||||
|
if (payload.messageId != null) 'message_id': payload.messageId,
|
||||||
},
|
},
|
||||||
callingNotification: const NotificationParams(showNotification: false),
|
callingNotification: const NotificationParams(showNotification: false),
|
||||||
|
missedCallNotification: const NotificationParams(
|
||||||
|
showNotification: true,
|
||||||
|
subtitle: iosMissedCallSubtitle,
|
||||||
|
callbackText: iosMissedCallCallbackText,
|
||||||
|
),
|
||||||
android: const AndroidParams(
|
android: const AndroidParams(
|
||||||
isCustomNotification: true,
|
isCustomNotification: true,
|
||||||
isShowLogo: false,
|
isShowLogo: false,
|
||||||
@@ -57,10 +132,65 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
ios: const IOSParams(
|
ios: const IOSParams(
|
||||||
handleType: 'generic',
|
handleType: 'generic',
|
||||||
supportsVideo: true,
|
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.callId != null) 'call_id': payload.callId!,
|
||||||
|
if (payload.counterpartName != null)
|
||||||
|
'caller_name': payload.counterpartName!,
|
||||||
|
if (payload.counterpartAvatarUrl != null)
|
||||||
|
'caller_avatar': payload.counterpartAvatarUrl!,
|
||||||
|
if (payload.localParticipantName != null)
|
||||||
|
'local_participant_name': payload.localParticipantName!,
|
||||||
|
if (payload.counterpartIsConsultant != null)
|
||||||
|
'is_consultant': payload.counterpartIsConsultant!
|
||||||
|
? 'true'
|
||||||
|
: 'false',
|
||||||
|
if (payload.messageId != null)
|
||||||
|
'message_id': payload.messageId.toString(),
|
||||||
|
if (payload.expiresAt != null)
|
||||||
|
'expires_at': payload.expiresAt!.toUtc().toIso8601String(),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> _claimOrFalse(
|
||||||
|
VideoCallIncomingClaim claim,
|
||||||
|
String callId,
|
||||||
|
) 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
/// Resolves the CallKit / ConnectionService id for an incoming call.
|
||||||
///
|
///
|
||||||
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
/// Prefer the backend `call_id` (stable for the whole session). Legacy
|
||||||
/// id is derived from that uuid plus the chat message id, so one ring is one
|
/// fallback: conversation uuid + message id on iOS, conversation uuid on
|
||||||
/// call and the next ring in the same chat still appears. Android notifications
|
/// Android.
|
||||||
/// keep the conversation uuid: the same id updates one incoming notification.
|
|
||||||
abstract final class VideoCallKitId {
|
abstract final class VideoCallKitId {
|
||||||
static final RegExp _uuidPrefix = RegExp(
|
static final RegExp _uuidPrefix = RegExp(
|
||||||
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static String resolve({
|
||||||
|
String? callId,
|
||||||
|
required String conversationUuid,
|
||||||
|
int? messageId,
|
||||||
|
required bool isIos,
|
||||||
|
}) {
|
||||||
|
final serverCallId = callId?.trim();
|
||||||
|
if (serverCallId != null && serverCallId.isNotEmpty) {
|
||||||
|
return serverCallId;
|
||||||
|
}
|
||||||
|
if (isIos) {
|
||||||
|
return iosUuid(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
messageId: messageId,
|
||||||
|
) ??
|
||||||
|
conversationUuid;
|
||||||
|
}
|
||||||
|
return conversationUuid;
|
||||||
|
}
|
||||||
|
|
||||||
static String? iosUuid({
|
static String? iosUuid({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
required int? messageId,
|
required int? messageId,
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ class VideoCallNotificationNavigator {
|
|||||||
return isOnCallRoute();
|
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() {
|
static bool isOnCallRoute() {
|
||||||
final matches =
|
final matches =
|
||||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||||
|
|||||||
133
lib/features/video_calls/services/video_call_peer_departure.dart
Normal file
133
lib/features/video_calls/services/video_call_peer_departure.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hang up reports the end the same way the web client does after it leaves
|
||||||
|
/// the room. The server then sends the ended chat message and push when no
|
||||||
|
/// other person is left in the room, which drops the call on the other devices.
|
||||||
|
bool shouldNotifyCallEnded({
|
||||||
|
required bool alreadyNotified,
|
||||||
|
required bool hasConversation,
|
||||||
|
}) {
|
||||||
|
if (!hasConversation || alreadyNotified) 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starting an incoming call must not run [endCall]. That dismisses the
|
||||||
|
/// CallKit call which just activated the microphone, and an empty
|
||||||
|
/// conversation id dismisses every active CallKit call.
|
||||||
|
bool shouldPreserveCallKitWhenStartingCall({
|
||||||
|
required String previousConversationUuid,
|
||||||
|
required String nextConversationUuid,
|
||||||
|
}) {
|
||||||
|
final previous = previousConversationUuid.trim();
|
||||||
|
final next = nextConversationUuid.trim();
|
||||||
|
if (previous.isEmpty) return true;
|
||||||
|
return previous == next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /decline` and `POST /ended` treat `call_id` as the session uuid.
|
||||||
|
/// The conversation uuid is a different id; sending it makes the request 404
|
||||||
|
/// and the other devices never hear about the hang-up.
|
||||||
|
String? backendVideoCallId({
|
||||||
|
required String? callId,
|
||||||
|
required String conversationUuid,
|
||||||
|
}) {
|
||||||
|
final id = callId?.trim() ?? '';
|
||||||
|
if (id.isEmpty) return null;
|
||||||
|
if (id == conversationUuid.trim()) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [startCall] emits an empty idle snapshot while it replaces the room.
|
||||||
|
/// A real failure (lost link or a connect error) still has to reach the
|
||||||
|
/// visible call screen, otherwise the loading screen never leaves.
|
||||||
|
bool isTransientIdleSnapshotWhileCallScreenVisible({
|
||||||
|
required bool callScreenVisible,
|
||||||
|
required String conversationUuid,
|
||||||
|
required bool needsManualReconnect,
|
||||||
|
required String? connectError,
|
||||||
|
}) {
|
||||||
|
if (!callScreenVisible) return false;
|
||||||
|
if (needsManualReconnect) return false;
|
||||||
|
if (connectError != null && connectError.trim().isNotEmpty) return false;
|
||||||
|
return conversationUuid.trim().isEmpty;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ class VideoCallPushPayload {
|
|||||||
this.localParticipantName,
|
this.localParticipantName,
|
||||||
this.counterpartIsConsultant,
|
this.counterpartIsConsultant,
|
||||||
this.messageId,
|
this.messageId,
|
||||||
|
this.callId,
|
||||||
|
this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String conversationUuid;
|
final String conversationUuid;
|
||||||
@@ -14,6 +16,8 @@ class VideoCallPushPayload {
|
|||||||
final String? localParticipantName;
|
final String? localParticipantName;
|
||||||
final bool? counterpartIsConsultant;
|
final bool? counterpartIsConsultant;
|
||||||
final int? messageId;
|
final int? messageId;
|
||||||
|
final String? callId;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||||
if (!isIncomingVideoCall(data)) return null;
|
if (!isIncomingVideoCall(data)) return null;
|
||||||
@@ -52,6 +56,8 @@ class VideoCallPushPayload {
|
|||||||
'isConsultant',
|
'isConsultant',
|
||||||
]),
|
]),
|
||||||
messageId: _messageId(data),
|
messageId: _messageId(data),
|
||||||
|
callId: _string(data, const ['call_id', 'callId']),
|
||||||
|
expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +125,9 @@ class VideoCallPushPayload {
|
|||||||
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
||||||
_conversationUuid(data);
|
_conversationUuid(data);
|
||||||
|
|
||||||
|
static String? callIdFromData(Map<String, dynamic> data) =>
|
||||||
|
_string(data, const ['call_id', 'callId']);
|
||||||
|
|
||||||
static String? _conversationUuid(Map<String, dynamic> data) {
|
static String? _conversationUuid(Map<String, dynamic> data) {
|
||||||
final fromData = _string(data, const [
|
final fromData = _string(data, const [
|
||||||
'conversationUuid',
|
'conversationUuid',
|
||||||
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static DateTime? _dateTime(Map<String, dynamic> data, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = data[key];
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
return DateTime.tryParse(value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isTruthy(Object? value) {
|
static bool _isTruthy(Object? value) {
|
||||||
if (value == null) return false;
|
if (value == null) return false;
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ abstract class VideoCallService {
|
|||||||
@GET('/api/v2/chat/{conversation}/video-call/token')
|
@GET('/api/v2/chat/{conversation}/video-call/token')
|
||||||
Future<VideoCallTokenResponse> getParticipantToken({
|
Future<VideoCallTokenResponse> getParticipantToken({
|
||||||
@Path('conversation') required String conversationUuid,
|
@Path('conversation') required String conversationUuid,
|
||||||
|
@Query('call_id') String? callId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
|
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
|
||||||
@@ -29,4 +30,10 @@ abstract class VideoCallService {
|
|||||||
@Path('conversation') required String conversationUuid,
|
@Path('conversation') required String conversationUuid,
|
||||||
@Body() required Map<String, dynamic> body,
|
@Body() required Map<String, dynamic> body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@POST('/api/v2/chat/{conversation}/video-call/decline')
|
||||||
|
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||||
|
@Path('conversation') required String conversationUuid,
|
||||||
|
@Body() required Map<String, dynamic> body,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
|
|
||||||
|
/// `video-call-declined` / `video-call-ended` on `private-new-chat-messages`.
|
||||||
|
///
|
||||||
|
/// The web client closes every other device from these events. A decline does
|
||||||
|
/// not create a chat message, so the open app has to read the socket itself.
|
||||||
|
class VideoCallSocketSignal {
|
||||||
|
const VideoCallSocketSignal({
|
||||||
|
required this.endsActiveCall,
|
||||||
|
this.conversationUuid,
|
||||||
|
this.callId,
|
||||||
|
this.reason,
|
||||||
|
this.acceptedDeviceId,
|
||||||
|
this.acceptedClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool endsActiveCall;
|
||||||
|
final String? conversationUuid;
|
||||||
|
final String? callId;
|
||||||
|
final VideoCallEndReason? reason;
|
||||||
|
final String? acceptedDeviceId;
|
||||||
|
final String? acceptedClient;
|
||||||
|
|
||||||
|
static VideoCallSocketSignal? tryParseDeclined(Object? raw) {
|
||||||
|
return _tryParse(raw, declined: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static VideoCallSocketSignal? tryParseEnded(Object? raw) {
|
||||||
|
return _tryParse(raw, declined: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static VideoCallSocketSignal? _tryParse(
|
||||||
|
Object? raw, {
|
||||||
|
required bool declined,
|
||||||
|
}) {
|
||||||
|
final data = _asMap(raw);
|
||||||
|
if (data == null) return null;
|
||||||
|
|
||||||
|
final conversationUuid = _string(data, const [
|
||||||
|
'conversation_uuid',
|
||||||
|
'conversationUuid',
|
||||||
|
]);
|
||||||
|
final callId = _string(data, const ['call_id', 'callId']);
|
||||||
|
if ((conversationUuid == null || conversationUuid.isEmpty) &&
|
||||||
|
(callId == null || callId.isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reason = declined
|
||||||
|
? VideoCallEndReason.declined
|
||||||
|
: VideoCallEndReason.tryParse(data['reason'] ?? data['status']);
|
||||||
|
final endsActiveCall = declined || reason != VideoCallEndReason.accepted;
|
||||||
|
|
||||||
|
return VideoCallSocketSignal(
|
||||||
|
endsActiveCall: endsActiveCall,
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: callId,
|
||||||
|
reason: reason,
|
||||||
|
acceptedDeviceId: _string(data, const [
|
||||||
|
'accepted_device_id',
|
||||||
|
'acceptedDeviceId',
|
||||||
|
]),
|
||||||
|
acceptedClient: _string(data, const [
|
||||||
|
'accepted_client',
|
||||||
|
'acceptedClient',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _asMap(Object? raw) {
|
||||||
|
if (raw is Map) {
|
||||||
|
return raw.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
}
|
||||||
|
if (raw is! String || raw.trim().isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return decoded.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _string(Map<String, dynamic> data, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = data[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// Remembers cancelled / declined call ids so a late `video_call_started`
|
||||||
|
/// push does not show an already-finished ring.
|
||||||
|
abstract final class VideoCallTombstoneRegistry {
|
||||||
|
static const Duration defaultTtl = Duration(minutes: 2);
|
||||||
|
|
||||||
|
static final Map<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
|
||||||
|
|
||||||
|
static void mark(
|
||||||
|
String? callId, {
|
||||||
|
DateTime? now,
|
||||||
|
Duration ttl = defaultTtl,
|
||||||
|
}) {
|
||||||
|
final id = _normalize(callId);
|
||||||
|
if (id == null) return;
|
||||||
|
final at = now ?? DateTime.now();
|
||||||
|
_expiresAtByCallId[id] = at.add(ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool contains(String? callId, {DateTime? now}) {
|
||||||
|
final id = _normalize(callId);
|
||||||
|
if (id == null) return false;
|
||||||
|
_prune(now ?? DateTime.now());
|
||||||
|
return _expiresAtByCallId.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void clear() => _expiresAtByCallId.clear();
|
||||||
|
|
||||||
|
static void _prune(DateTime now) {
|
||||||
|
_expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _normalize(String? callId) {
|
||||||
|
final id = callId?.trim().toLowerCase();
|
||||||
|
if (id == null || id.isEmpty) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
pubspec.yaml
22
pubspec.yaml
@@ -1,6 +1,6 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.50+139
|
version: 1.3.67+156
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -175,14 +175,14 @@ flutter_intl:
|
|||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
color: '#FFFFFF'
|
color: "#FFFFFF"
|
||||||
image_android: 'assets/splash/splash.png'
|
image_android: "assets/splash/splash.png"
|
||||||
image_dark_android: 'assets/splash/splash.png'
|
image_dark_android: "assets/splash/splash.png"
|
||||||
image_ios: 'assets/splash/splash.png'
|
image_ios: "assets/splash/splash.png"
|
||||||
image_dark_ios: 'assets/splash/splash.png'
|
image_dark_ios: "assets/splash/splash.png"
|
||||||
branding_android: 'assets/splash/branding.png'
|
branding_android: "assets/splash/branding.png"
|
||||||
branding_dark_android: 'assets/splash/branding.png'
|
branding_dark_android: "assets/splash/branding.png"
|
||||||
android_12:
|
android_12:
|
||||||
image: 'assets/splash/splash.png'
|
image: "assets/splash/splash.png"
|
||||||
image_dark: 'assets/splash/splash.png'
|
image_dark: "assets/splash/splash.png"
|
||||||
branding: 'assets/splash/branding.png'
|
branding: "assets/splash/branding.png"
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.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/env_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.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: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_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mockito/mockito.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() {
|
void main() {
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
// Initialize dotenv for tests
|
// 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 {
|
setUp(() async {
|
||||||
@@ -90,6 +98,83 @@ void main() {
|
|||||||
expect(devConfig.baseUrl, testConfig.baseUrl);
|
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',
|
||||||
|
);
|
||||||
|
expect(prefs.getString(PrefsKeys.apiBaseUrl), '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();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(sl<AppConfig>().baseUrl, 'https://cached.example');
|
||||||
|
expect(sl<Dio>().options.baseUrl, 'https://cached.example');
|
||||||
|
expect(
|
||||||
|
sl<Prefs>().getString(PrefsKeys.apiBaseUrl),
|
||||||
|
'https://cached.example',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(sl<AppConfig>().baseUrl, EnvConfig.baseUrl);
|
||||||
|
expect(sl<Dio>().options.baseUrl, EnvConfig.baseUrl);
|
||||||
|
expect(sl<Prefs>().getString(PrefsKeys.apiBaseUrl), EnvConfig.baseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
test('setBaseUrl can be called multiple times', () {
|
test('setBaseUrl can be called multiple times', () {
|
||||||
final config = AppConfig(buildType: AppBuildType.prod);
|
final config = AppConfig(buildType: AppBuildType.prod);
|
||||||
const url1 = 'https://url1.com';
|
const url1 = 'https://url1.com';
|
||||||
|
|||||||
23
test/core/device/device_hw_id_provider_test.dart
Normal file
23
test/core/device/device_hw_id_provider_test.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('android install id stays the same for this install', () async {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
final first = await DeviceHwIdProvider.androidInstallId(
|
||||||
|
preferences: preferences,
|
||||||
|
);
|
||||||
|
final second = await DeviceHwIdProvider.androidInstallId(
|
||||||
|
preferences: preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.length, greaterThanOrEqualTo(8));
|
||||||
|
expect(second, first);
|
||||||
|
expect(first.contains('-'), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,57 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
group('PrefsKeys', () {
|
group('PrefsKeys', () {
|
||||||
test('has all expected values', () {
|
test('has all expected values', () {
|
||||||
expect(PrefsKeys.values.length, 5);
|
expect(PrefsKeys.values.length, 7);
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
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.isEmailVerificationSkipped));
|
||||||
|
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
|
||||||
|
expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be compared', () {
|
test('can be compared', () {
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
|
expect(
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
|
PrefsKeys.hasAnonymousTokenBeenSent ==
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has correct name property', () {
|
test('has correct name property', () {
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
|
expect(
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent.name,
|
||||||
|
'hasAnonymousTokenBeenSent',
|
||||||
|
);
|
||||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
expect(
|
||||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
PrefsKeys.marketingBottomSheetHasBeenShown.name,
|
||||||
|
'marketingBottomSheetHasBeenShown',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.isEmailVerificationSkipped.name,
|
||||||
|
'isEmailVerificationSkipped',
|
||||||
|
);
|
||||||
|
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
|
||||||
|
expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ void main() {
|
|||||||
expect(() => interceptor.onError(error, handler), throwsA(isA<Error>()));
|
expect(() => interceptor.onError(error, handler), throwsA(isA<Error>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('onError - 405 с missing: [email] без navigator-контекста отклоняет запрос без падения', () async {
|
||||||
|
final requestOptions = RequestOptions(path: '/test');
|
||||||
|
final response = Response(requestOptions: requestOptions, statusCode: 405, data: {'type': 'unverificated_phone', 'reason': 'verification_required', 'missing': ['email']});
|
||||||
|
final error = DioException(requestOptions: requestOptions, response: response, type: DioExceptionType.badResponse);
|
||||||
|
|
||||||
|
final handler = MockErrorInterceptorHandler();
|
||||||
|
when(() => handler.reject(any())).thenReturn(null);
|
||||||
|
|
||||||
|
// В тестах AppRouter.navigatorKey.currentContext == null: email-ветка должна это пережить.
|
||||||
|
interceptor.onError(error, handler);
|
||||||
|
|
||||||
|
// onError объявлен как void, дожидаемся завершения асинхронной обработки.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
verify(() => handler.reject(any())).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('onError - пропускает другие ошибки', () async {
|
test('onError - пропускает другие ошибки', () async {
|
||||||
final requestOptions = RequestOptions(path: '/', method: 'GET', baseUrl: 'http://localhost');
|
final requestOptions = RequestOptions(path: '/', method: 'GET', baseUrl: 'http://localhost');
|
||||||
final response = Response(requestOptions: requestOptions, statusCode: 404, data: {});
|
final response = Response(requestOptions: requestOptions, statusCode: 404, data: {});
|
||||||
|
|||||||
91
test/core/interceptors/verification_requirement_test.dart
Normal file
91
test/core/interceptors/verification_requirement_test.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('VerificationRequirement.tryParse', () {
|
||||||
|
test('missing: [email] — не хватает только почты', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
'missing': ['email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsEmail, isTrue);
|
||||||
|
expect(requirement.needsPhone, isFalse);
|
||||||
|
expect(requirement.missing, [VerificationChannel.email]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing: [phone] — не хватает только телефона', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
'missing': ['phone'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsPhone, isTrue);
|
||||||
|
expect(requirement.needsEmail, isFalse);
|
||||||
|
expect(requirement.missing, [VerificationChannel.phone]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing: [phone, email] — не хватает обоих каналов', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
'missing': ['phone', 'email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsPhone, isTrue);
|
||||||
|
expect(requirement.needsEmail, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing отсутствует — legacy-контракт «телефон»', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsPhone, isTrue);
|
||||||
|
expect(requirement.needsEmail, isFalse);
|
||||||
|
expect(requirement.missing, [VerificationChannel.phone]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing пустой — legacy-контракт «телефон»', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
'missing': [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsPhone, isTrue);
|
||||||
|
expect(requirement.needsEmail, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('неизвестное значение в missing игнорируется', () {
|
||||||
|
final requirement = VerificationRequirement.tryParse({
|
||||||
|
'type': 'unverificated_phone',
|
||||||
|
'missing': ['email', 'telegram'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requirement, isNotNull);
|
||||||
|
expect(requirement!.needsEmail, isTrue);
|
||||||
|
expect(requirement.needsPhone, isFalse);
|
||||||
|
expect(requirement.missing, [VerificationChannel.email]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data не Map — null', () {
|
||||||
|
expect(VerificationRequirement.tryParse(null), isNull);
|
||||||
|
expect(VerificationRequirement.tryParse('unverificated_phone'), isNull);
|
||||||
|
expect(VerificationRequirement.tryParse(['email']), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type не unverificated_phone — null', () {
|
||||||
|
expect(
|
||||||
|
VerificationRequirement.tryParse({
|
||||||
|
'type': 'other',
|
||||||
|
'missing': ['email'],
|
||||||
|
}),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,25 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
group('PrefsKeys', () {
|
group('PrefsKeys', () {
|
||||||
test('все значения enum определены', () {
|
test('все значения enum определены', () {
|
||||||
expect(PrefsKeys.values.length, 5);
|
expect(PrefsKeys.values.length, 7);
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
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.isEmailVerificationSkipped));
|
||||||
|
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
|
||||||
|
expect(PrefsKeys.values, contains(PrefsKeys.apiBaseUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('имена ключей корректны', () {
|
test('имена ключей корректны', () {
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
|
expect(
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent.name,
|
||||||
|
'hasAnonymousTokenBeenSent',
|
||||||
|
);
|
||||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
expect(
|
||||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
PrefsKeys.marketingBottomSheetHasBeenShown.name,
|
||||||
|
'marketingBottomSheetHasBeenShown',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.isEmailVerificationSkipped.name,
|
||||||
|
'isEmailVerificationSkipped',
|
||||||
|
);
|
||||||
|
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
|
||||||
|
expect(PrefsKeys.apiBaseUrl.name, 'apiBaseUrl');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be compared', () {
|
test('can be compared', () {
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
|
expect(
|
||||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
|
PrefsKeys.hasAnonymousTokenBeenSent ==
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has correct index', () {
|
test('has correct index', () {
|
||||||
@@ -33,43 +56,22 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('can access by name', () {
|
test('can access by name', () {
|
||||||
expect(PrefsKeys.values.byName('hasAnonymousTokenBeenSent'), PrefsKeys.hasAnonymousTokenBeenSent);
|
expect(
|
||||||
expect(PrefsKeys.values.byName('welcomeScreenWasShowed'), PrefsKeys.welcomeScreenWasShowed);
|
PrefsKeys.values.byName('hasAnonymousTokenBeenSent'),
|
||||||
expect(PrefsKeys.values.byName('registrationDate'), PrefsKeys.registrationDate);
|
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||||
expect(PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'), PrefsKeys.marketingBottomSheetHasBeenShown);
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.values.byName('welcomeScreenWasShowed'),
|
||||||
|
PrefsKeys.welcomeScreenWasShowed,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.values.byName('registrationDate'),
|
||||||
|
PrefsKeys.registrationDate,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'),
|
||||||
|
PrefsKeys.marketingBottomSheetHasBeenShown,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ void main() {
|
|||||||
required int authorId,
|
required int authorId,
|
||||||
required String roomId,
|
required String roomId,
|
||||||
String data = 'socket message',
|
String data = 'socket message',
|
||||||
|
String? callId,
|
||||||
}) {
|
}) {
|
||||||
return _FakeChannelReadEvent(
|
return _FakeChannelReadEvent(
|
||||||
jsonEncode(<String, dynamic>{
|
jsonEncode(<String, dynamic>{
|
||||||
@@ -100,6 +101,7 @@ void main() {
|
|||||||
'read_by_to_user': false,
|
'read_by_to_user': false,
|
||||||
'is_author_message_auth_user': false,
|
'is_author_message_auth_user': false,
|
||||||
'room_id': roomId,
|
'room_id': roomId,
|
||||||
|
'call_id': ?callId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -418,8 +420,10 @@ void main() {
|
|||||||
() async {
|
() async {
|
||||||
const chatId = 'chat-id';
|
const chatId = 'chat-id';
|
||||||
final presented = <String>[];
|
final presented = <String>[];
|
||||||
|
String? presentedCallId;
|
||||||
chatsCubit.presentIncomingVideoCall = (payload) async {
|
chatsCubit.presentIncomingVideoCall = (payload) async {
|
||||||
presented.add(payload.conversationUuid);
|
presented.add(payload.conversationUuid);
|
||||||
|
presentedCallId = payload.callId;
|
||||||
};
|
};
|
||||||
chatsCubit.emit(
|
chatsCubit.emit(
|
||||||
ChatsState(
|
ChatsState(
|
||||||
@@ -436,11 +440,13 @@ void main() {
|
|||||||
authorId: 7,
|
authorId: 7,
|
||||||
roomId: chatId,
|
roomId: chatId,
|
||||||
data: 'Анна начал(а) видеозвонок.',
|
data: 'Анна начал(а) видеозвонок.',
|
||||||
|
callId: '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(presented, [chatId]);
|
expect(presented, [chatId]);
|
||||||
|
expect(presentedCallId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff');
|
||||||
|
|
||||||
await controller.close();
|
await controller.close();
|
||||||
},
|
},
|
||||||
@@ -479,6 +485,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 {
|
test('close - закрывает cubit и отписывается от подписок', () async {
|
||||||
final mockSubscription1 = MockStreamSubscription();
|
final mockSubscription1 = MockStreamSubscription();
|
||||||
|
|
||||||
@@ -994,6 +1033,120 @@ void main() {
|
|||||||
await reconnectController.close();
|
await reconnectController.close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('video-call-declined closes the call on this device', () async {
|
||||||
|
final declined = StreamController<ChannelReadEvent>.broadcast();
|
||||||
|
when(mockWsManager.newMessagesStream).thenReturn(null);
|
||||||
|
when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(
|
||||||
|
mockWsManager.videoCallDeclinedStream,
|
||||||
|
).thenAnswer((_) => declined.stream);
|
||||||
|
when(mockWsManager.videoCallEndedStream).thenReturn(null);
|
||||||
|
|
||||||
|
final dismissed = <String?>[];
|
||||||
|
final ended = <String?>[];
|
||||||
|
chatsCubit.dismissIncomingVideoCall = (uuid) async {
|
||||||
|
dismissed.add(uuid);
|
||||||
|
};
|
||||||
|
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
|
||||||
|
ended.add(conversationUuid);
|
||||||
|
expect(callId, 'call-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await chatsCubit.connectToWebSocket();
|
||||||
|
declined.add(
|
||||||
|
_FakeChannelReadEvent(
|
||||||
|
jsonEncode(<String, dynamic>{
|
||||||
|
'call_id': 'call-1',
|
||||||
|
'conversation_uuid': 'conv-1',
|
||||||
|
'status': 'declined',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(dismissed, ['conv-1']);
|
||||||
|
expect(ended, ['conv-1']);
|
||||||
|
await declined.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video-call-accepted closes ringing on the other device', () async {
|
||||||
|
final accepted = StreamController<ChannelReadEvent>.broadcast();
|
||||||
|
when(mockWsManager.newMessagesStream).thenReturn(null);
|
||||||
|
when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(mockWsManager.videoCallDeclinedStream).thenReturn(null);
|
||||||
|
when(mockWsManager.videoCallEndedStream).thenReturn(null);
|
||||||
|
when(
|
||||||
|
mockWsManager.videoCallAcceptedStream,
|
||||||
|
).thenAnswer((_) => accepted.stream);
|
||||||
|
chatsCubit.currentDeviceHwId = () async => 'device-iphone';
|
||||||
|
|
||||||
|
final dismissed = <String?>[];
|
||||||
|
final ended = <String?>[];
|
||||||
|
chatsCubit.dismissIncomingVideoCall = (uuid) async {
|
||||||
|
dismissed.add(uuid);
|
||||||
|
};
|
||||||
|
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
|
||||||
|
ended.add(conversationUuid);
|
||||||
|
};
|
||||||
|
|
||||||
|
await chatsCubit.connectToWebSocket();
|
||||||
|
accepted.add(
|
||||||
|
_FakeChannelReadEvent(
|
||||||
|
jsonEncode(<String, dynamic>{
|
||||||
|
'call_id': 'call-1',
|
||||||
|
'conversation_uuid': 'conv-1',
|
||||||
|
'status': 'accepted',
|
||||||
|
'accepted_device_id': 'device-android',
|
||||||
|
'accepted_client': 'android',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(dismissed, ['conv-1']);
|
||||||
|
expect(ended, isEmpty);
|
||||||
|
await accepted.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'video-call-accepted keeps the call on the device that answered',
|
||||||
|
() async {
|
||||||
|
final accepted = StreamController<ChannelReadEvent>.broadcast();
|
||||||
|
when(mockWsManager.newMessagesStream).thenReturn(null);
|
||||||
|
when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(mockWsManager.videoCallDeclinedStream).thenReturn(null);
|
||||||
|
when(mockWsManager.videoCallEndedStream).thenReturn(null);
|
||||||
|
when(
|
||||||
|
mockWsManager.videoCallAcceptedStream,
|
||||||
|
).thenAnswer((_) => accepted.stream);
|
||||||
|
chatsCubit.currentDeviceHwId = () async => 'Device-iPhone';
|
||||||
|
|
||||||
|
final dismissed = <String?>[];
|
||||||
|
chatsCubit.dismissIncomingVideoCall = (uuid) async {
|
||||||
|
dismissed.add(uuid);
|
||||||
|
};
|
||||||
|
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
|
||||||
|
fail('accepted call must stay on the answering device');
|
||||||
|
};
|
||||||
|
|
||||||
|
await chatsCubit.connectToWebSocket();
|
||||||
|
accepted.add(
|
||||||
|
_FakeChannelReadEvent(
|
||||||
|
jsonEncode(<String, dynamic>{
|
||||||
|
'call_id': 'call-1',
|
||||||
|
'conversation_uuid': 'conv-1',
|
||||||
|
'status': 'accepted',
|
||||||
|
'accepted_device_id': 'device-iphone',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(dismissed, isEmpty);
|
||||||
|
await accepted.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
group('PortionState enum', () {
|
group('PortionState enum', () {
|
||||||
test('has all expected values', () {
|
test('has all expected values', () {
|
||||||
expect(PortionState.values.length, 7);
|
expect(PortionState.values.length, 10);
|
||||||
expect(PortionState.filledDiet, isNotNull);
|
expect(PortionState.filledDiet, isNotNull);
|
||||||
expect(PortionState.eatenFromDiet, isNotNull);
|
expect(PortionState.eatenFromDiet, isNotNull);
|
||||||
expect(PortionState.eaten, isNotNull);
|
expect(PortionState.eaten, isNotNull);
|
||||||
expect(PortionState.eatenDeleted, isNotNull);
|
expect(PortionState.eatenDeleted, isNotNull);
|
||||||
expect(PortionState.eatenEdited, isNotNull);
|
expect(PortionState.eatenEdited, isNotNull);
|
||||||
expect(PortionState.commented, isNotNull);
|
expect(PortionState.commented, isNotNull);
|
||||||
|
expect(PortionState.commentedFromDiet, isNotNull);
|
||||||
expect(PortionState.reviewedByUser, 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
|
|||||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_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_lifecycle_binding.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
@@ -49,10 +51,14 @@ void main() {
|
|||||||
videoCallRepository: mockRepository,
|
videoCallRepository: mockRepository,
|
||||||
foregroundCoordinator: foreground,
|
foregroundCoordinator: foreground,
|
||||||
lifecycleBinding: lifecycleBinding,
|
lifecycleBinding: lifecycleBinding,
|
||||||
|
resolveHwId: () async => 'test-hw-id-android-01',
|
||||||
);
|
);
|
||||||
mockRoom = MockRoom();
|
mockRoom = MockRoom();
|
||||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||||
when(mockRoom.localParticipant).thenReturn(null);
|
when(mockRoom.localParticipant).thenReturn(null);
|
||||||
|
when(
|
||||||
|
mockRoom.remoteParticipants,
|
||||||
|
).thenReturn(UnmodifiableMapView(<String, lk.RemoteParticipant>{}));
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -77,6 +83,57 @@ void main() {
|
|||||||
expect(foreground.startCount, 0);
|
expect(foreground.startCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'decline right after hang up does not notify the backend again',
|
||||||
|
() async {
|
||||||
|
manager.markActiveCallForTesting(mockRoom);
|
||||||
|
|
||||||
|
await manager.endCall();
|
||||||
|
clearInteractions(mockRepository);
|
||||||
|
|
||||||
|
await manager.declineIncomingCall('test-uuid');
|
||||||
|
|
||||||
|
verifyNever(mockRepository.notifyCallEnded(any));
|
||||||
|
verifyNever(
|
||||||
|
mockRepository.notifyCallDeclined(
|
||||||
|
conversationUuid: anyNamed('conversationUuid'),
|
||||||
|
hwId: anyNamed('hwId'),
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'startCall right after hang up does not request a new token',
|
||||||
|
() async {
|
||||||
|
manager.markActiveCallForTesting(mockRoom);
|
||||||
|
await manager.endCall();
|
||||||
|
clearInteractions(mockRepository);
|
||||||
|
|
||||||
|
final started = await manager.startCall(conversationUuid: 'test-uuid');
|
||||||
|
|
||||||
|
expect(started, isFalse);
|
||||||
|
verifyNever(mockRepository.getParticipantToken(any));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'decline of a call that was never joined does not POST /decline from Dart',
|
||||||
|
() async {
|
||||||
|
await manager.declineIncomingCall('other-uuid');
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
mockRepository.notifyCallDeclined(
|
||||||
|
conversationUuid: anyNamed('conversationUuid'),
|
||||||
|
hwId: anyNamed('hwId'),
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
verifyNever(mockRepository.notifyCallEnded(any));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('endCall stops foreground service', () async {
|
test('endCall stops foreground service', () async {
|
||||||
manager.markActiveCallForTesting(mockRoom);
|
manager.markActiveCallForTesting(mockRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -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', () {
|
test('isMicActiveForUi mirrors micEnabled', () {
|
||||||
const state = VideoCallSessionState(micEnabled: true);
|
const state = VideoCallSessionState(micEnabled: true);
|
||||||
expect(state.isMicActiveForUi, isTrue);
|
expect(state.isMicActiveForUi, isTrue);
|
||||||
@@ -145,6 +175,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
|
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(
|
cubit.emit(
|
||||||
const VideoCallSessionState(
|
const VideoCallSessionState(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -169,7 +211,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(
|
await cubit.onCallScreenVisible(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -201,6 +243,7 @@ void main() {
|
|||||||
linkIssue: VideoCallLinkIssue.none,
|
linkIssue: VideoCallLinkIssue.none,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -214,7 +257,7 @@ void main() {
|
|||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
counterpartName: anyNamed('counterpartName'),
|
counterpartName: anyNamed('counterpartName'),
|
||||||
localParticipantName: anyNamed('localParticipantName'),
|
localParticipantName: anyNamed('localParticipantName'),
|
||||||
micEnabled: false,
|
micEnabled: true,
|
||||||
videoEnabled: false,
|
videoEnabled: false,
|
||||||
),
|
),
|
||||||
).called(1);
|
).called(1);
|
||||||
@@ -232,7 +275,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -267,7 +310,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -298,7 +341,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(
|
await cubit.onCallScreenVisible(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -314,7 +357,7 @@ void main() {
|
|||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
counterpartName: anyNamed('counterpartName'),
|
counterpartName: anyNamed('counterpartName'),
|
||||||
localParticipantName: anyNamed('localParticipantName'),
|
localParticipantName: anyNamed('localParticipantName'),
|
||||||
micEnabled: false,
|
micEnabled: true,
|
||||||
videoEnabled: false,
|
videoEnabled: false,
|
||||||
),
|
),
|
||||||
).called(1);
|
).called(1);
|
||||||
@@ -332,7 +375,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
cubit.emit(
|
cubit.emit(
|
||||||
const VideoCallSessionState(
|
const VideoCallSessionState(
|
||||||
@@ -360,7 +403,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
||||||
expect(cubit.isCallScreenRouteVisible, isTrue);
|
expect(cubit.isCallScreenRouteVisible, isTrue);
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ void main() {
|
|||||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||||
final expected = VideoCallTokenResponse.fromJson(json);
|
final expected = VideoCallTokenResponse.fromJson(json);
|
||||||
|
|
||||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
when(mockService.getParticipantToken(
|
||||||
.thenAnswer((_) async => expected);
|
conversationUuid: conversationUuid,
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
)).thenAnswer((_) async => expected);
|
||||||
|
|
||||||
final result = await repository.getParticipantToken(conversationUuid);
|
final result = await repository.getParticipantToken(conversationUuid);
|
||||||
|
|
||||||
@@ -33,20 +35,28 @@ void main() {
|
|||||||
expect(result.token, expected.token);
|
expect(result.token, expected.token);
|
||||||
expect(result.wsUrl, expected.wsUrl);
|
expect(result.wsUrl, expected.wsUrl);
|
||||||
expect(result.roomName, expected.roomName);
|
expect(result.roomName, expected.roomName);
|
||||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
verify(mockService.getParticipantToken(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: null,
|
||||||
|
)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
|
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
|
||||||
const conversationUuid = 'conv-uuid-broken';
|
const conversationUuid = 'conv-uuid-broken';
|
||||||
|
|
||||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
when(mockService.getParticipantToken(
|
||||||
.thenThrow(Exception('No token'));
|
conversationUuid: conversationUuid,
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
)).thenThrow(Exception('No token'));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => repository.getParticipantToken(conversationUuid),
|
() => repository.getParticipantToken(conversationUuid),
|
||||||
throwsA(isA<Exception>()),
|
throwsA(isA<Exception>()),
|
||||||
);
|
);
|
||||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
verify(mockService.getParticipantToken(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: null,
|
||||||
|
)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
|
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
|
||||||
@@ -91,6 +101,60 @@ void main() {
|
|||||||
expect(captured.single as Map<String, dynamic>, isEmpty);
|
expect(captured.single as Map<String, dynamic>, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('notifyCallEnded - передаёт call_id когда он известен', () async {
|
||||||
|
const conversationUuid = 'conv-uuid-123';
|
||||||
|
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||||
|
final expected = VideoCallEndedResponse.fromJson(json);
|
||||||
|
|
||||||
|
when(mockService.notifyCallEnded(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
body: anyNamed('body'),
|
||||||
|
)).thenAnswer((_) async => expected);
|
||||||
|
|
||||||
|
await repository.notifyCallEnded(conversationUuid, callId: 'call-1');
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
mockService.notifyCallEnded(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
body: captureAnyNamed('body'),
|
||||||
|
),
|
||||||
|
).captured.single as Map<String, dynamic>;
|
||||||
|
expect(captured, <String, dynamic>{'call_id': 'call-1'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyCallDeclined - передаёт hw_id и call_id', () async {
|
||||||
|
const conversationUuid = 'conv-uuid-123';
|
||||||
|
final json = FixtureReader.readAsMap(
|
||||||
|
'video_calls/video_call_decline_response.json',
|
||||||
|
);
|
||||||
|
final expected = VideoCallDeclineResponse.fromJson(json);
|
||||||
|
|
||||||
|
when(mockService.notifyCallDeclined(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
body: anyNamed('body'),
|
||||||
|
)).thenAnswer((_) async => expected);
|
||||||
|
|
||||||
|
final result = await repository.notifyCallDeclined(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
hwId: 'device-android-001',
|
||||||
|
callId: 'call-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sent, isTrue);
|
||||||
|
expect(result.devicesNotified, 1);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
mockService.notifyCallDeclined(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
body: captureAnyNamed('body'),
|
||||||
|
),
|
||||||
|
).captured.single as Map<String, dynamic>;
|
||||||
|
expect(captured, <String, dynamic>{
|
||||||
|
'hw_id': 'device-android-001',
|
||||||
|
'call_id': 'call-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
|
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
|
||||||
const conversationUuid = 'conv-uuid-broken';
|
const conversationUuid = 'conv-uuid-broken';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -20,9 +21,65 @@ void main() {
|
|||||||
final resumed = await coordinator.reactivate();
|
final resumed = await coordinator.reactivate();
|
||||||
expect(resumed, isTrue);
|
expect(resumed, isTrue);
|
||||||
|
|
||||||
|
await coordinator.restartCapture();
|
||||||
|
expect(coordinator.isActive, isTrue);
|
||||||
|
|
||||||
await coordinator.stop();
|
await coordinator.stop();
|
||||||
expect(coordinator.isActive, isFalse);
|
expect(coordinator.isActive, isFalse);
|
||||||
expect(calls, ['activate', 'activate', 'deactivate']);
|
expect(calls, ['activate', 'activate', 'restart', 'deactivate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startCallKitCapture starts the AudioEngine ADM without stopping', () async {
|
||||||
|
final adm = <String>[];
|
||||||
|
final coordinator = VideoCallAudioSessionCoordinator(
|
||||||
|
isIos: true,
|
||||||
|
iosCallAudioSession: (_) async {},
|
||||||
|
stopLocalRecording: () async => adm.add('stop'),
|
||||||
|
startLocalRecording: () async => adm.add('start'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.startCallKitCapture();
|
||||||
|
|
||||||
|
expect(coordinator.isActive, isTrue);
|
||||||
|
expect(adm, ['start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop stops the AudioEngine ADM', () async {
|
||||||
|
final adm = <String>[];
|
||||||
|
final coordinator = VideoCallAudioSessionCoordinator(
|
||||||
|
isIos: true,
|
||||||
|
iosCallAudioSession: (_) async {},
|
||||||
|
stopLocalRecording: () async => adm.add('stop'),
|
||||||
|
startLocalRecording: () async => adm.add('start'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.startCallKitCapture();
|
||||||
|
await coordinator.stop();
|
||||||
|
|
||||||
|
expect(coordinator.isActive, isFalse);
|
||||||
|
expect(adm, ['start', 'stop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwards a later CallKit audio activation', () async {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final coordinator = VideoCallAudioSessionCoordinator(isIos: true);
|
||||||
|
var activations = 0;
|
||||||
|
coordinator.onIosAudioSessionActivated = () async {
|
||||||
|
activations++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const codec = StandardMethodCodec();
|
||||||
|
final data = codec.encodeMethodCall(
|
||||||
|
const MethodCall('audioSessionActivated'),
|
||||||
|
);
|
||||||
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.handlePlatformMessage(
|
||||||
|
'com.eatvioapp/call_audio_session',
|
||||||
|
data,
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(activations, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stop clears the active session when deactivate fails', () async {
|
test('stop clears the active session when deactivate fails', () async {
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ void main() {
|
|||||||
expect(payload.messageId, 1);
|
expect(payload.messageId, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the server call id on the incoming payload', () {
|
||||||
|
final payload = VideoCallChatSignal.incomingPayload(
|
||||||
|
message: message(data: 'Анна начал(а) видеозвонок.'),
|
||||||
|
callId: ' 6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff ',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload?.callId, '6f1e8c0a-1b2c-4d3e-8f90-aabbccddeeff');
|
||||||
|
});
|
||||||
|
|
||||||
test('recognizes an english realtime video call start', () {
|
test('recognizes an english realtime video call start', () {
|
||||||
expect(
|
expect(
|
||||||
VideoCallChatSignal.kindOf(message(data: 'Anna started a video call.')),
|
VideoCallChatSignal.kindOf(message(data: 'Anna started a video call.')),
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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.callMatches', () {
|
||||||
|
test('matches by call_id and ignores conversation fallback', () {
|
||||||
|
const call = CallKitParams(
|
||||||
|
id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
handle: conversation,
|
||||||
|
extra: <String, dynamic>{
|
||||||
|
'conversationUuid': conversation,
|
||||||
|
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
VideoCallEndedPushHandler.callMatches(
|
||||||
|
call: call,
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
VideoCallEndedPushHandler.callMatches(
|
||||||
|
call: call,
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: 'stale-call-id',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a legacy ring without call_id still matches the conversation', () {
|
||||||
|
const call = CallKitParams(
|
||||||
|
id: conversation,
|
||||||
|
handle: conversation,
|
||||||
|
extra: <String, dynamic>{'conversationUuid': conversation},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
VideoCallEndedPushHandler.callMatches(
|
||||||
|
call: call,
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
late List<Map<String, Object?>> activeCalls;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
calls.clear();
|
||||||
|
VideoCallRemoteEndRegistry.clear();
|
||||||
|
activeCalls = <Map<String, Object?>>[
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'ringing-id',
|
||||||
|
'handle': conversation,
|
||||||
|
'extra': <String, Object?>{'conversationUuid': conversation},
|
||||||
|
},
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'other-id',
|
||||||
|
'handle': otherConversation,
|
||||||
|
'extra': <String, Object?>{'conversationUuid': otherConversation},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(callKitChannel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'activeCalls') return activeCalls;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(callKitChannel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ends only the matching call and marks it as a remote end', () async {
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ended = calls
|
||||||
|
.where((call) => call.method == 'endCall')
|
||||||
|
.map((call) => (call.arguments as Map)['id'])
|
||||||
|
.toList();
|
||||||
|
expect(ended, <String>['ringing-id']);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ends only the matching call_id', () async {
|
||||||
|
activeCalls = <Map<String, Object?>>[
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'call-a',
|
||||||
|
'handle': conversation,
|
||||||
|
'extra': <String, Object?>{
|
||||||
|
'conversationUuid': conversation,
|
||||||
|
'call_id': 'call-a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'call-b',
|
||||||
|
'handle': conversation,
|
||||||
|
'extra': <String, Object?>{
|
||||||
|
'conversationUuid': conversation,
|
||||||
|
'call_id': 'call-b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: 'call-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
final ended = calls
|
||||||
|
.where((call) => call.method == 'endCall')
|
||||||
|
.map((call) => (call.arguments as Map)['id'])
|
||||||
|
.toList();
|
||||||
|
expect(ended, <String>['call-a']);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'without a conversation ends every call and marks all of them',
|
||||||
|
() async {
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
||||||
|
|
||||||
|
expect(calls.map((call) => call.method), contains('endAllCalls'));
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('dismissCallKitOnly ends the matching CallKit call', () async {
|
||||||
|
await VideoCallEndedPushHandler.dismissCallKitOnly(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ended = calls
|
||||||
|
.where((call) => call.method == 'endCall')
|
||||||
|
.map((call) => (call.arguments as Map)['id'])
|
||||||
|
.toList();
|
||||||
|
expect(ended, <String>['ringing-id']);
|
||||||
|
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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('reports the end so the other devices drop the call', () {
|
||||||
|
expect(
|
||||||
|
shouldNotifyCallEnded(
|
||||||
|
alreadyNotified: false,
|
||||||
|
hasConversation: true,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldNotifyCallEnded(
|
||||||
|
alreadyNotified: true,
|
||||||
|
hasConversation: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldNotifyCallEnded(
|
||||||
|
alreadyNotified: false,
|
||||||
|
hasConversation: 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('an incoming start does not tear down the accepted CallKit call', () {
|
||||||
|
expect(
|
||||||
|
shouldPreserveCallKitWhenStartingCall(
|
||||||
|
previousConversationUuid: '',
|
||||||
|
nextConversationUuid: 'conv-1',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldPreserveCallKitWhenStartingCall(
|
||||||
|
previousConversationUuid: 'conv-1',
|
||||||
|
nextConversationUuid: 'conv-1',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldPreserveCallKitWhenStartingCall(
|
||||||
|
previousConversationUuid: 'conv-old',
|
||||||
|
nextConversationUuid: 'conv-1',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the conversation uuid is not sent as the session call id', () {
|
||||||
|
expect(
|
||||||
|
backendVideoCallId(callId: 'conv-1', conversationUuid: 'conv-1'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
backendVideoCallId(
|
||||||
|
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
conversationUuid: 'conv-1',
|
||||||
|
),
|
||||||
|
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
backendVideoCallId(callId: ' ', conversationUuid: 'conv-1'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lost connection on the visible call screen is not ignored', () {
|
||||||
|
expect(
|
||||||
|
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||||
|
callScreenVisible: true,
|
||||||
|
conversationUuid: '',
|
||||||
|
needsManualReconnect: false,
|
||||||
|
connectError: null,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||||
|
callScreenVisible: true,
|
||||||
|
conversationUuid: 'conv-1',
|
||||||
|
needsManualReconnect: true,
|
||||||
|
connectError: 'timeout',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||||
|
callScreenVisible: false,
|
||||||
|
conversationUuid: '',
|
||||||
|
needsManualReconnect: false,
|
||||||
|
connectError: null,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -57,6 +57,25 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(payload, isNotNull);
|
expect(payload, isNotNull);
|
||||||
|
expect(payload!.conversationUuid, 'abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses ended video call push with call_id and reason', () {
|
||||||
|
final payload = VideoCallEndedPushPayload.tryParse({
|
||||||
|
'type': 'video_call_ended',
|
||||||
|
'conversation_uuid': 'abc-123',
|
||||||
|
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
'reason': 'declined',
|
||||||
|
'accepted_device_id': 'device-a',
|
||||||
|
'accepted_client': 'android',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload, isNotNull);
|
||||||
|
expect(payload!.conversationUuid, 'abc-123');
|
||||||
|
expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||||
|
expect(payload.reason?.name, 'declined');
|
||||||
|
expect(payload.acceptedDeviceId, 'device-a');
|
||||||
|
expect(payload.acceptedClient, 'android');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses message id for a stable CallKit uuid', () {
|
test('parses message id for a stable CallKit uuid', () {
|
||||||
@@ -76,6 +95,28 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prefers call_id as CallKit id when present', () {
|
||||||
|
final payload = VideoCallPushPayload.tryParse({
|
||||||
|
'type': 'video_call_started',
|
||||||
|
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
'message_id': '42',
|
||||||
|
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
'expires_at': '2026-04-27T14:31:00+00:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||||
|
expect(payload?.expiresAt, isNotNull);
|
||||||
|
expect(
|
||||||
|
VideoCallKitId.resolve(
|
||||||
|
callId: payload!.callId,
|
||||||
|
conversationUuid: payload.conversationUuid,
|
||||||
|
messageId: payload.messageId,
|
||||||
|
isIos: true,
|
||||||
|
),
|
||||||
|
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('ignores regular chat push', () {
|
test('ignores regular chat push', () {
|
||||||
expect(
|
expect(
|
||||||
VideoCallPushPayload.tryParse({
|
VideoCallPushPayload.tryParse({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,23 +21,32 @@ void main() {
|
|||||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||||
final expected = VideoCallTokenResponse.fromJson(json);
|
final expected = VideoCallTokenResponse.fromJson(json);
|
||||||
|
|
||||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
when(mockService.getParticipantToken(
|
||||||
.thenAnswer((_) async => expected);
|
conversationUuid: conversationUuid,
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
)).thenAnswer((_) async => expected);
|
||||||
|
|
||||||
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
|
final result = await mockService.getParticipantToken(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result, isA<VideoCallTokenResponse>());
|
expect(result, isA<VideoCallTokenResponse>());
|
||||||
expect(result.token, expected.token);
|
expect(result.token, expected.token);
|
||||||
expect(result.wsUrl, expected.wsUrl);
|
expect(result.wsUrl, expected.wsUrl);
|
||||||
expect(result.roomName, expected.roomName);
|
expect(result.roomName, expected.roomName);
|
||||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
verify(mockService.getParticipantToken(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
callId: null,
|
||||||
|
)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getParticipantToken - пробрасывает ошибку', () async {
|
test('getParticipantToken - пробрасывает ошибку', () async {
|
||||||
const conversationUuid = 'conv-uuid-error';
|
const conversationUuid = 'conv-uuid-error';
|
||||||
|
|
||||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
when(mockService.getParticipantToken(
|
||||||
.thenThrow(Exception('Network error'));
|
conversationUuid: conversationUuid,
|
||||||
|
callId: anyNamed('callId'),
|
||||||
|
)).thenThrow(Exception('Network error'));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => mockService.getParticipantToken(conversationUuid: conversationUuid),
|
() => mockService.getParticipantToken(conversationUuid: conversationUuid),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('decline closes the caller call and the other ringing devices', () {
|
||||||
|
final signal = VideoCallSocketSignal.tryParseDeclined(
|
||||||
|
jsonEncode(<String, dynamic>{
|
||||||
|
'call_id': 'call-1',
|
||||||
|
'conversation_uuid': 'conv-1',
|
||||||
|
'status': 'declined',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(signal, isNotNull);
|
||||||
|
expect(signal!.endsActiveCall, isTrue);
|
||||||
|
expect(signal.conversationUuid, 'conv-1');
|
||||||
|
expect(signal.callId, 'call-1');
|
||||||
|
expect(signal.reason, VideoCallEndReason.declined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ended closes an active call', () {
|
||||||
|
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
|
||||||
|
'conversation_uuid': 'conv-1',
|
||||||
|
'reason': 'cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signal?.endsActiveCall, isTrue);
|
||||||
|
expect(signal?.reason, VideoCallEndReason.cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an accept marker does not tear down the call this device joined', () {
|
||||||
|
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
|
||||||
|
'call_id': 'call-1',
|
||||||
|
'status': 'accepted',
|
||||||
|
'accepted_device_id': 'device-iphone',
|
||||||
|
'accepted_client': 'ios',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signal?.endsActiveCall, isFalse);
|
||||||
|
expect(signal?.reason, VideoCallEndReason.accepted);
|
||||||
|
expect(signal?.acceptedDeviceId, 'device-iphone');
|
||||||
|
expect(signal?.acceptedClient, 'ios');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a payload without a call or a conversation', () {
|
||||||
|
expect(
|
||||||
|
VideoCallSocketSignal.tryParseDeclined(<String, dynamic>{
|
||||||
|
'status': 'declined',
|
||||||
|
}),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
tearDown(VideoCallTombstoneRegistry.clear);
|
||||||
|
|
||||||
|
test('marks a call id so a late incoming can be suppressed', () {
|
||||||
|
const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b';
|
||||||
|
final now = DateTime.utc(2026, 10, 21, 10);
|
||||||
|
|
||||||
|
VideoCallTombstoneRegistry.mark(callId, now: now);
|
||||||
|
|
||||||
|
expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue);
|
||||||
|
expect(
|
||||||
|
VideoCallTombstoneRegistry.contains(
|
||||||
|
callId,
|
||||||
|
now: now.add(const Duration(minutes: 3)),
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
6
test/fixtures/video_calls/video_call_decline_response.json
vendored
Normal file
6
test/fixtures/video_calls/video_call_decline_response.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sent": true,
|
||||||
|
"devices_notified": 1,
|
||||||
|
"call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b",
|
||||||
|
"status": "declined"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user