forked from eatvio/eatvio_app
Compare commits
14 Commits
6d2935400a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b94f835c | ||
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
| 293601f1e1 | |||
| 6a259e5452 |
@@ -87,8 +87,11 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
||||
<!-- The incoming notification's content intent and full-screen intent both open
|
||||
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||
CLEAR_TASK flag from killing MainActivity. -->
|
||||
<activity
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
tools:node="remove" />
|
||||
@@ -97,20 +100,26 @@
|
||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation" />
|
||||
|
||||
<activity-alias
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation"
|
||||
android:permission="${applicationId}.PERMISSION_CALL">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,12 +1,71 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
|
||||
import com.hiennv.flutter_callkit_incoming.Utils
|
||||
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.abs
|
||||
import com.hiennv.flutter_callkit_incoming.R as CallkitR
|
||||
|
||||
/// Redirects notification body / full-screen intent taps to the same accept path as
|
||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
||||
/// Target of the incoming notification's content intent and full-screen intent.
|
||||
///
|
||||
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
|
||||
/// accept flow as the Answer button.
|
||||
///
|
||||
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
|
||||
/// ringing screen that wakes the device and lets the user answer or decline.
|
||||
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||
companion object {
|
||||
private const val TAG = "EatvioIncomingBridge"
|
||||
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
|
||||
private const val DEFAULT_RING_DURATION_MS = 45_000L
|
||||
private const val MIN_RING_SCREEN_MS = 1_000L
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
|
||||
|
||||
/**
|
||||
* Closes the ringing screen once the call stopped ringing: answered or declined
|
||||
* from the notification, timed out, or ended by the caller.
|
||||
*/
|
||||
fun dismissRingingScreen() {
|
||||
mainHandler.post {
|
||||
ringingScreen?.get()?.closeRingingScreen()
|
||||
ringingScreen = null
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
|
||||
return keyguardLocked || !screenInteractive
|
||||
}
|
||||
}
|
||||
|
||||
private var callData: Bundle? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var ringTimeout: Runnable? = null
|
||||
private var actionTaken = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
callData = data
|
||||
|
||||
CallKitIntentBridge.dispatchCallKitAction(
|
||||
this,
|
||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
||||
data,
|
||||
if (!launchedOnLockedOrDarkScreen()) {
|
||||
Log.i(TAG, "tap on notification body: accept")
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
|
||||
showRingingScreen(data)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
|
||||
if (ringingScreen?.get() !== this) return
|
||||
callData = data
|
||||
bindCallData(data)
|
||||
scheduleRingTimeout(data)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelRingTimeout()
|
||||
releaseWakeLock()
|
||||
if (ringingScreen?.get() === this) {
|
||||
ringingScreen = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
// The ringing screen is dismissed by Answer / Decline / timeout only.
|
||||
}
|
||||
|
||||
private fun launchedOnLockedOrDarkScreen(): Boolean {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return shouldShowRingingScreen(
|
||||
keyguardLocked = keyguardManager.isKeyguardLocked,
|
||||
screenInteractive = powerManager.isInteractive,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showRingingScreen(data: Bundle) {
|
||||
ringingScreen = WeakReference(this)
|
||||
|
||||
setTheme(CallkitR.style.CallkitIncomingTheme)
|
||||
requestedOrientation = if (Utils.isTablet(this)) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setTurnScreenOn(true)
|
||||
setShowWhenLocked(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
|
||||
acquireWakeLock(ringDuration(data))
|
||||
VideoCallPushBridge.keepRingingOnFullScreen()
|
||||
|
||||
setContentView(CallkitR.layout.activity_callkit_incoming)
|
||||
bindCallData(data)
|
||||
scheduleRingTimeout(data)
|
||||
}
|
||||
|
||||
private fun bindCallData(data: Bundle) {
|
||||
val background: ImageView = findViewById(CallkitR.id.ivBackground)
|
||||
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
|
||||
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
|
||||
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
|
||||
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
|
||||
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
|
||||
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
|
||||
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
|
||||
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
|
||||
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
|
||||
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
|
||||
|
||||
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
|
||||
ripple.startRippleAnimation()
|
||||
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
|
||||
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
|
||||
actions.layoutParams = params
|
||||
}
|
||||
|
||||
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
|
||||
callerNumber.visibility = View.INVISIBLE
|
||||
logo.visibility = View.INVISIBLE
|
||||
|
||||
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
|
||||
callerName.setTextColor(textColor)
|
||||
acceptLabel.setTextColor(textColor)
|
||||
declineLabel.setTextColor(textColor)
|
||||
background.setBackgroundColor(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
|
||||
)
|
||||
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(0, 0)
|
||||
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||
if (!avatarUrl.isNullOrEmpty()) {
|
||||
avatar.visibility = View.VISIBLE
|
||||
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
|
||||
try {
|
||||
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to load caller avatar", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
|
||||
acceptButton.setImageResource(CallkitR.drawable.ic_video)
|
||||
}
|
||||
acceptButton.background = AppUtils.createCircleDrawable(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
|
||||
)
|
||||
declineButton.background = AppUtils.createCircleDrawable(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
|
||||
)
|
||||
|
||||
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
|
||||
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
|
||||
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
|
||||
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
|
||||
|
||||
acceptButton.setOnClickListener { onAcceptClick() }
|
||||
declineButton.setOnClickListener { onDeclineClick() }
|
||||
}
|
||||
|
||||
private fun onAcceptClick() {
|
||||
val data = callData ?: return
|
||||
if (actionTaken) return
|
||||
actionTaken = true
|
||||
cancelRingTimeout()
|
||||
Log.i(TAG, "ringing screen: accept")
|
||||
|
||||
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
|
||||
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
|
||||
// becomes visible as soon as the user unlocks.
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
if (keyguardManager.isKeyguardLocked) {
|
||||
keyguardManager.requestDismissKeyguard(this, null)
|
||||
}
|
||||
}
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun onDeclineClick() {
|
||||
val data = callData ?: return
|
||||
if (actionTaken) return
|
||||
actionTaken = true
|
||||
cancelRingTimeout()
|
||||
Log.i(TAG, "ringing screen: decline")
|
||||
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun closeRingingScreen() {
|
||||
if (isFinishing) return
|
||||
cancelRingTimeout()
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun finishRingingTask() {
|
||||
if (isFinishing) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
finishAndRemoveTask()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ringDuration(data: Bundle): Long {
|
||||
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
|
||||
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification was posted before this screen appeared, so ring only for the
|
||||
* remaining part of the call duration.
|
||||
*/
|
||||
private fun scheduleRingTimeout(data: Bundle) {
|
||||
cancelRingTimeout()
|
||||
val now = System.currentTimeMillis()
|
||||
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
|
||||
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
|
||||
val timeout = Runnable {
|
||||
Log.i(TAG, "ringing screen: timed out")
|
||||
closeRingingScreen()
|
||||
}
|
||||
ringTimeout = timeout
|
||||
mainHandler.postDelayed(timeout, remaining)
|
||||
}
|
||||
|
||||
private fun cancelRingTimeout() {
|
||||
ringTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
ringTimeout = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWakeLock(durationMs: Long) {
|
||||
try {
|
||||
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val lock = powerManager.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
PowerManager.ON_AFTER_RELEASE,
|
||||
WAKE_LOCK_TAG,
|
||||
)
|
||||
lock.acquire(durationMs)
|
||||
wakeLock = lock
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to acquire wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
val lock = wakeLock ?: return
|
||||
wakeLock = null
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to release wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(value: String?, fallback: Int): Int {
|
||||
if (value.isNullOrBlank()) return fallback
|
||||
return try {
|
||||
Color.parseColor(value)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ class MainActivity: FlutterActivity() {
|
||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
"showIncomingCall" -> {
|
||||
try {
|
||||
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
val data = HashMap<String, String>()
|
||||
for ((key, value) in args) {
|
||||
val name = key as? String ?: continue
|
||||
if (value == null) continue
|
||||
data[name] = value.toString()
|
||||
}
|
||||
VideoCallPushBridge.showIncomingCall(applicationContext, data)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
"drainPendingCallKitAccept" -> {
|
||||
result.success(PendingCallKitAcceptBridge.drain())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
@@ -24,6 +25,7 @@ object VideoCallPushBridge {
|
||||
private const val TAG = "VideoCallPushBridge"
|
||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val playbackLock = Any()
|
||||
@@ -34,6 +36,7 @@ object VideoCallPushBridge {
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -121,17 +124,26 @@ object VideoCallPushBridge {
|
||||
return
|
||||
}
|
||||
|
||||
val generation = playbackGeneration.incrementAndGet()
|
||||
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
|
||||
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||
val seenGeneration = playbackGeneration.get()
|
||||
Log.i(TAG, "showIncomingCall: conversation=$callId")
|
||||
|
||||
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||
// pushes on a background thread, while Answer runs on the main thread.
|
||||
// A second show for the same conversation must not bump the generation:
|
||||
// that would cancel the timeout of the call that is already ringing.
|
||||
mainHandler.post {
|
||||
synchronized(playbackLock) {
|
||||
if (generation != playbackGeneration.get()) {
|
||||
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
|
||||
if (seenGeneration != playbackGeneration.get()) {
|
||||
Log.i(TAG, "showIncomingCall: skipped after silence")
|
||||
return@post
|
||||
}
|
||||
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
|
||||
Log.i(TAG, "showIncomingCall: already ringing $callId")
|
||||
return@post
|
||||
}
|
||||
val generation = playbackGeneration.incrementAndGet()
|
||||
startIncomingCallLocked(appContext, callData, generation)
|
||||
}
|
||||
}
|
||||
@@ -158,6 +170,16 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
|
||||
* the display on itself, so keep ringing through that transition.
|
||||
*/
|
||||
fun keepRingingOnFullScreen() {
|
||||
synchronized(playbackLock) {
|
||||
activeSoundManager?.keepRingingOnFullScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
@@ -230,6 +252,7 @@ object VideoCallPushBridge {
|
||||
}
|
||||
|
||||
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
|
||||
if (durationMs > 0L) {
|
||||
val timeout = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
@@ -243,8 +266,43 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the screen on while the call rings. The full-screen intent of the
|
||||
* notification does the same only when the system grants it; the wake lock
|
||||
* works without any grant, so the lock screen with the call card lights up.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
|
||||
releaseScreenWakeLockLocked()
|
||||
try {
|
||||
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val lock = powerManager.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
PowerManager.ON_AFTER_RELEASE,
|
||||
WAKE_LOCK_TAG,
|
||||
)
|
||||
lock.acquire(durationMs)
|
||||
ringWakeLock = lock
|
||||
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseScreenWakeLockLocked() {
|
||||
val lock = ringWakeLock ?: return
|
||||
ringWakeLock = null
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPlaybackLocked(appContext: Context) {
|
||||
cancelRingtoneTimeoutLocked()
|
||||
releaseScreenWakeLockLocked()
|
||||
val notificationManager = activeNotificationManager
|
||||
val bundle = activeCallBundle
|
||||
stopIncomingRingtone()
|
||||
@@ -258,6 +316,7 @@ object VideoCallPushBridge {
|
||||
activeSoundManager = null
|
||||
activeNotificationManager = null
|
||||
activeCallBundle = null
|
||||
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||
}
|
||||
|
||||
private fun cancelRingtoneTimeoutLocked() {
|
||||
|
||||
@@ -15,6 +15,8 @@ import flutter_foreground_task
|
||||
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
|
||||
private let pendingAcceptedCallTtl: TimeInterval = 120
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private let appUrlScheme = "eatvio"
|
||||
@@ -234,6 +236,26 @@ import flutter_foreground_task
|
||||
return nil
|
||||
}
|
||||
|
||||
private func voipBool(_ value: Any?) -> Bool? {
|
||||
if let bool = value as? Bool {
|
||||
return bool
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return number.intValue != 0
|
||||
}
|
||||
if let text = value as? String {
|
||||
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -282,16 +304,31 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
private func storePendingAcceptedCall(_ call: Call) {
|
||||
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
|
||||
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
|
||||
}
|
||||
|
||||
/// An accept that Flutter never consumed (the process died before the
|
||||
/// scene opened) must not open the call screen on a later launch: that
|
||||
/// screen requests a token, and the token request rings the other person.
|
||||
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
return nil
|
||||
}
|
||||
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
|
||||
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
|
||||
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
|
||||
recordVoipDiagnostic(
|
||||
"pending_accepted_call_expired",
|
||||
data: [
|
||||
"callKitId": payload["id"] as? String ?? "",
|
||||
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
|
||||
]
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -515,6 +552,13 @@ import flutter_foreground_task
|
||||
handle: conversation,
|
||||
type: 1
|
||||
)
|
||||
// The plugin sends this payload with its "ended" event. Dart uses the
|
||||
// marker to tell a remote end from a decline by the user.
|
||||
var endExtra: [String: Any] = ["endedPush": true]
|
||||
if !conversation.isEmpty {
|
||||
endExtra["conversationUuid"] = conversation
|
||||
}
|
||||
endData.extra = endExtra as NSDictionary
|
||||
plugin.endCall(endData)
|
||||
releaseIncomingCallClaim(id)
|
||||
}
|
||||
@@ -686,6 +730,18 @@ import flutter_foreground_task
|
||||
} else if !nameCaller.isEmpty {
|
||||
extra["counterpartName"] = nameCaller
|
||||
}
|
||||
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
|
||||
extra["counterpartIsConsultant"] = isConsultant
|
||||
}
|
||||
// Closing this report looks like a user decline. Dart must not tell the
|
||||
// backend, or the server sends another ended VoIP and the other phone
|
||||
// shows it as a new incoming call.
|
||||
if isEndedPush {
|
||||
extra["endedPush"] = true
|
||||
}
|
||||
// An ended push with no ringing call still has to be reported. That
|
||||
// report is closed at once, so it must not leave a trace anywhere.
|
||||
let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation"
|
||||
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
@@ -693,15 +749,24 @@ import flutter_foreground_task
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.appName = "Итвио"
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
|
||||
data.missedNotificationCallbackText = "Перезвонить"
|
||||
data.isShowMissedCallNotification = !isEndedPush
|
||||
data.includesCallsInRecents = !isPhantomEndedReport
|
||||
// The plugin activates AVAudioSession itself on answer and again 1.2s
|
||||
// later. That drops the ringing UI and leaves one side silent. CallKit
|
||||
// and WebRTC own the session instead.
|
||||
data.configureAudioSession = false
|
||||
data.audioSessionActive = false
|
||||
data.audioSessionMode = "videoChat"
|
||||
data.ringtonePath = "system_ringtone_default"
|
||||
// Do not set ringtonePath. The plugin stores that string as the ringtone
|
||||
// file name. "system_ringtone_default" is not in the app, so CallKit ends
|
||||
// the call at once and PushKit delivers the same push again.
|
||||
// Remember the id so the in-app socket path does not report it again.
|
||||
// The push itself must still be reported, or iOS kills the process.
|
||||
_ = reserveIncomingCall(id)
|
||||
@@ -728,18 +793,25 @@ import flutter_foreground_task
|
||||
callKitIdSourceName: callKitIdSourceName,
|
||||
conversationUuid: conversationUuid,
|
||||
endAfterReport: isEndedPush,
|
||||
endImmediately: isPhantomEndedReport,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
||||
/// its completion handler runs. Both happen here, including an "ended" push.
|
||||
///
|
||||
/// `endImmediately` is for an ended push that matched no ringing call. The
|
||||
/// report is closed inside its own completion, before the incoming UI has a
|
||||
/// chance to show. An end transaction would take one more round trip and
|
||||
/// would also reach `onDecline`, which resets the in-app audio restart.
|
||||
private func reportVoipPushToCallKit(
|
||||
_ data: flutter_callkit_incoming.Data,
|
||||
callKitId: String,
|
||||
callKitIdSourceName: String,
|
||||
conversationUuid: String,
|
||||
endAfterReport: Bool,
|
||||
endImmediately: Bool,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
let gate = VoipPushCompletion(completion)
|
||||
@@ -749,6 +821,12 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
let afterReport = {
|
||||
if endAfterReport, endImmediately,
|
||||
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
|
||||
{
|
||||
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
|
||||
plugin.saveEndCall(callKitId, 6)
|
||||
}
|
||||
if endAfterReport {
|
||||
DispatchQueue.main.async {
|
||||
self.endCallsForEndedPush(
|
||||
|
||||
@@ -69,13 +69,13 @@ class FirebaseApi {
|
||||
}
|
||||
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
@@ -89,14 +89,17 @@ class FirebaseApi {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
// Incoming calls on iOS come only through the VoIP token. It does not
|
||||
// depend on the FCM token, so a missing or failed FCM registration must
|
||||
// not leave the device without calls.
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
ChatRoomStateLoaded state,
|
||||
ChatUserModel chatCounterpart,
|
||||
) {
|
||||
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
|
||||
@@ -18,8 +18,13 @@ enum PortionState {
|
||||
@JsonValue('eaten_edited')
|
||||
eatenEdited,
|
||||
commented,
|
||||
@JsonValue('commented_from_diet')
|
||||
commentedFromDiet,
|
||||
@JsonValue('reviewed_by_user')
|
||||
reviewedByUser,
|
||||
@JsonValue('reviewed_from_diet')
|
||||
reviewedFromDiet,
|
||||
unknown,
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
|
||||
@JsonKey(name: "quantity") required int quantity,
|
||||
@JsonKey(name: "portion") required String portion,
|
||||
@JsonKey(name: "from_diet") required bool fromDiet,
|
||||
@JsonKey(name: "state") required PortionState portionState,
|
||||
@JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
|
||||
}) = _PortionModel;
|
||||
|
||||
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);
|
||||
|
||||
@@ -77,6 +77,8 @@ class VideoCallManager {
|
||||
bool _liveKitDisposed = true;
|
||||
bool _callEndedNotified = true;
|
||||
bool _forceEndRequested = false;
|
||||
bool _sessionMayRequestToken = false;
|
||||
final Map<String, DateTime> _leftConversationAt = {};
|
||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||
bool _callStageEntered = false;
|
||||
bool _joinInProgress = false;
|
||||
@@ -178,13 +180,29 @@ class VideoCallManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startCall({
|
||||
/// Lets the next [startCall] ask the server for a token.
|
||||
///
|
||||
/// Only the call button and an accepted incoming call set this. A screen
|
||||
/// that reappears after hang-up must not request a token: that request is
|
||||
/// what rings the other person again.
|
||||
void permitParticipantTokenRequest() {
|
||||
_sessionMayRequestToken = true;
|
||||
}
|
||||
|
||||
Future<bool> startCall({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool micEnabled = false,
|
||||
bool videoEnabled = false,
|
||||
}) async {
|
||||
final allowed = mayRequestParticipantToken(
|
||||
sessionPermitted: _sessionMayRequestToken,
|
||||
leftAt: _leftConversationAt[conversationUuid],
|
||||
now: DateTime.now(),
|
||||
);
|
||||
if (!allowed) return false;
|
||||
|
||||
if (_room != null &&
|
||||
!_liveKitDisposed &&
|
||||
_snapshot.conversationUuid == conversationUuid) {
|
||||
@@ -199,7 +217,7 @@ class VideoCallManager {
|
||||
);
|
||||
_ensureLifecycleAttached();
|
||||
unawaited(_startCallAudioSession());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (!_callStageEntered &&
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
@@ -213,11 +231,12 @@ class VideoCallManager {
|
||||
),
|
||||
);
|
||||
_ensureLifecycleAttached();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await endCall();
|
||||
_sessionMayRequestToken = true;
|
||||
|
||||
_resetSessionFlagsForNewRoom();
|
||||
_callEndedNotified = false;
|
||||
@@ -255,9 +274,10 @@ class VideoCallManager {
|
||||
);
|
||||
|
||||
await _prepareLiveKitConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> onCallScreenVisible({
|
||||
Future<bool> onCallScreenVisible({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
@@ -267,7 +287,7 @@ class VideoCallManager {
|
||||
if (_snapshot.conversationUuid != conversationUuid ||
|
||||
_room == null ||
|
||||
_liveKitDisposed) {
|
||||
await startCall(
|
||||
return startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
@@ -286,6 +306,7 @@ class VideoCallManager {
|
||||
unawaited(_ensureLocalParticipantNamePublished());
|
||||
_syncRemoteParticipantNameFromRoom();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +434,9 @@ class VideoCallManager {
|
||||
);
|
||||
if (uuid.isNotEmpty) {
|
||||
_callEndedNotified = true;
|
||||
_leftConversationAt[uuid] = DateTime.now();
|
||||
}
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
if (shouldNotifyBackend) {
|
||||
@@ -466,6 +489,12 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
await endCall();
|
||||
if (!incomingDeclineShouldNotifyBackend(
|
||||
leftAt: _leftConversationAt[uuid],
|
||||
now: DateTime.now(),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
} catch (_) {}
|
||||
@@ -977,6 +1006,7 @@ class VideoCallManager {
|
||||
Future<void> _prepareLiveKitConnection() async {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
@@ -1025,6 +1055,7 @@ class VideoCallManager {
|
||||
var wsUrl = _preparedWsUrl;
|
||||
var token = _preparedToken;
|
||||
if (wsUrl == null || token == null) {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
);
|
||||
@@ -1103,6 +1134,14 @@ class VideoCallManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _tokenRequestAllowed(String conversationUuid) {
|
||||
return mayRequestParticipantToken(
|
||||
sessionPermitted: _sessionMayRequestToken,
|
||||
leftAt: _leftConversationAt[conversationUuid],
|
||||
now: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _roomHasRemoteUser() {
|
||||
final room = _room;
|
||||
if (room == null) return false;
|
||||
|
||||
@@ -171,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onCallScreenVisible({
|
||||
Future<bool> onCallScreenVisible({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
@@ -225,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isResumingActiveCall) {
|
||||
@@ -255,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
await _videoCallManager.startCall(
|
||||
final started = await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
);
|
||||
if (!started) {
|
||||
if (!isClosed) emit(const VideoCallSessionState());
|
||||
return false;
|
||||
}
|
||||
emit(state.copyWith(entryMode: effectiveEntryMode));
|
||||
if (isIncomingAccepted) {
|
||||
await _tryAutoEnterCallStage();
|
||||
@@ -273,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _ensureSystemPipArmedIfInCall() async {
|
||||
|
||||
@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_initCounterpartMetadata());
|
||||
unawaited(
|
||||
_cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
),
|
||||
);
|
||||
unawaited(_openCallSession());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openCallSession() async {
|
||||
final started = await _cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
);
|
||||
if (!mounted || started) return;
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _initCounterpartMetadata() async {
|
||||
var isOnline = widget.counterpartIsOnline ?? false;
|
||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
|
||||
@@ -8,7 +8,9 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||
@@ -362,7 +364,7 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _handleDecline(id);
|
||||
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||
}
|
||||
|
||||
Future<void> _handleEndedEvent(CallEvent event) async {
|
||||
@@ -374,7 +376,7 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _handleEnded(id);
|
||||
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||
}
|
||||
|
||||
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||
@@ -842,6 +844,7 @@ class VideoCallCallKitCoordinator {
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
FlutterNativeSplash.remove();
|
||||
_manager.permitParticipantTokenRequest();
|
||||
|
||||
return VideoCallNotificationNavigator.openIncomingCall(
|
||||
conversationUuid: conversationUuid,
|
||||
@@ -853,7 +856,10 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDecline(String callKitId) async {
|
||||
Future<void> _handleDecline(
|
||||
String callKitId, {
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) async {
|
||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
||||
@@ -863,11 +869,29 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
|
||||
final params = _paramsForCallId(callKitId);
|
||||
final extra = params?.extra ?? <String, dynamic>{};
|
||||
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
|
||||
final conversationUuid = _parseConversationUuid(
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final remoteEndDismissal =
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
eventExtra: eventExtra,
|
||||
) ||
|
||||
VideoCallRemoteEndRegistry.contains(callKitId);
|
||||
|
||||
if (remoteEndDismissal) {
|
||||
// The call is already closed on the native side. Another endCall is
|
||||
// not needed: while the plugin's push flag is set it would end the
|
||||
// last reported call instead of this id, which may be a newer ring.
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
_forgetCallKitId(callKitId);
|
||||
return;
|
||||
}
|
||||
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
try {
|
||||
@@ -879,10 +903,7 @@ class VideoCallCallKitCoordinator {
|
||||
}),
|
||||
);
|
||||
}
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
}
|
||||
_forgetCallKitId(callKitId);
|
||||
|
||||
if (_manager.isActiveCall ||
|
||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||
@@ -896,7 +917,10 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleEnded(String callKitId) async {
|
||||
Future<void> _handleEnded(
|
||||
String callKitId, {
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) async {
|
||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
||||
@@ -905,16 +929,36 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
// iOS reports an ended VoIP push to CallKit and closes it right away.
|
||||
// That close is marked and must be remembered before the decline (or the
|
||||
// later timeout) event for the same id arrives with no marker.
|
||||
final remoteEnd = callKitDeclineIsEndedPush(
|
||||
cachedExtra: _paramsForCallId(callKitId)?.extra,
|
||||
eventExtra: eventExtra,
|
||||
);
|
||||
if (remoteEnd) {
|
||||
VideoCallRemoteEndRegistry.mark(callKitId);
|
||||
}
|
||||
|
||||
_forgetCallKitId(callKitId);
|
||||
|
||||
// Native already ends a remote-ended call. Calling endCall again here
|
||||
// overwrites the plugin's stored payload, so the decline that follows
|
||||
// would lose the marker.
|
||||
if (remoteEnd) return;
|
||||
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _forgetCallKitId(String callKitId) {
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _parseConversationUuid(
|
||||
Map<String, Object?> extra, {
|
||||
String? handle,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -53,16 +54,23 @@ abstract final class VideoCallEndedPushHandler {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The close of a ringing call comes back from the plugin as a decline.
|
||||
/// Each id is marked first, so the coordinator does not report that
|
||||
/// decline to the backend as if the user had rejected the call.
|
||||
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
for (final call in activeCalls) {
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
}
|
||||
await FlutterCallkitIncoming.endAllCalls();
|
||||
return;
|
||||
}
|
||||
for (final call in activeCalls) {
|
||||
if (!callMatchesConversation(call, uuid)) continue;
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(call.id);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
|
||||
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
|
||||
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
|
||||
typedef VideoCallAndroidIncomingShow =
|
||||
Future<void> Function(VideoCallPushPayload payload);
|
||||
|
||||
/// Shows the system incoming-call UI.
|
||||
///
|
||||
@@ -18,9 +20,16 @@ typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
|
||||
/// never arrived.
|
||||
abstract final class VideoCallIncomingPushPresenter {
|
||||
static const String _androidAccentColor = '#2787F5';
|
||||
|
||||
/// Same texts as the native VoIP path in `AppDelegate.swift`.
|
||||
static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок';
|
||||
static const String iosMissedCallCallbackText = 'Перезвонить';
|
||||
static const MethodChannel _voipChannel = MethodChannel(
|
||||
'com.eatvioapp/voip_diagnostics',
|
||||
);
|
||||
static const MethodChannel _androidPushChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
/// Absolute delays from the start of an iOS present attempt.
|
||||
static const List<Duration> iosClaimPollDelays = <Duration>[
|
||||
@@ -38,11 +47,16 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
VideoCallIncomingShow? showCallkitIncoming,
|
||||
VideoCallIncomingDelay? delay,
|
||||
List<Duration>? claimPollDelays,
|
||||
VideoCallAndroidIncomingShow? showAndroidIncoming,
|
||||
}) async {
|
||||
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
final ios = isIos ?? Platform.isIOS;
|
||||
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||
return;
|
||||
}
|
||||
final callId = ios
|
||||
? VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
@@ -87,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: true,
|
||||
subtitle: iosMissedCallSubtitle,
|
||||
callbackText: iosMissedCallCallbackText,
|
||||
),
|
||||
android: const AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
@@ -106,7 +125,6 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
configureAudioSession: false,
|
||||
audioSessionActive: false,
|
||||
audioSessionMode: 'videoChat',
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +133,27 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _showAndroidIncoming(VideoCallPushPayload payload) async {
|
||||
try {
|
||||
await _androidPushChannel
|
||||
.invokeMethod<void>('showIncomingCall', <String, String>{
|
||||
'conversation_uuid': payload.conversationUuid,
|
||||
if (payload.counterpartName != null)
|
||||
'caller_name': payload.counterpartName!,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
'caller_avatar': payload.counterpartAvatarUrl!,
|
||||
if (payload.localParticipantName != null)
|
||||
'local_participant_name': payload.localParticipantName!,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'is_consultant': payload.counterpartIsConsultant!
|
||||
? 'true'
|
||||
: 'false',
|
||||
if (payload.messageId != null)
|
||||
'message_id': payload.messageId.toString(),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<bool> _claimOrFalse(
|
||||
VideoCallIncomingClaim claim,
|
||||
String callId,
|
||||
|
||||
@@ -30,6 +30,58 @@ bool shouldNotifyCallEnded({
|
||||
return true;
|
||||
}
|
||||
|
||||
/// An ended VoIP push has to be reported to CallKit, then closed. That close
|
||||
/// arrives as a decline. Reporting it to the backend sends another ended VoIP,
|
||||
/// which the other phone shows as a new incoming call.
|
||||
const Duration incomingDeclineNotifySuppression = Duration(seconds: 60);
|
||||
|
||||
bool incomingDeclineShouldNotifyBackend({
|
||||
required DateTime? leftAt,
|
||||
required DateTime now,
|
||||
Duration suppressFor = incomingDeclineNotifySuppression,
|
||||
}) {
|
||||
if (leftAt == null) return true;
|
||||
return now.difference(leftAt) >= suppressFor;
|
||||
}
|
||||
|
||||
/// A token request after hang-up makes the server send a new incoming call.
|
||||
/// Automatic retries stay blocked until the user starts or accepts a call.
|
||||
bool mayRequestParticipantToken({
|
||||
required bool sessionPermitted,
|
||||
required DateTime? leftAt,
|
||||
required DateTime now,
|
||||
Duration suppressFor = incomingDeclineNotifySuppression,
|
||||
}) {
|
||||
if (sessionPermitted) return true;
|
||||
return incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: now,
|
||||
suppressFor: suppressFor,
|
||||
);
|
||||
}
|
||||
|
||||
bool callKitExtraMarksEndedPush(Map<String, dynamic>? extra) {
|
||||
final value = extra?['endedPush'];
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized == 'true' || normalized == '1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Either the cached incoming payload or the decline event itself can carry
|
||||
/// the marker. The decline often arrives with the push payload, while the
|
||||
/// cache still holds the original ringing call.
|
||||
bool callKitDeclineIsEndedPush({
|
||||
Map<String, dynamic>? cachedExtra,
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) {
|
||||
return callKitExtraMarksEndedPush(cachedExtra) ||
|
||||
callKitExtraMarksEndedPush(eventExtra);
|
||||
}
|
||||
|
||||
bool shouldForceEndCallAfterRoomDisconnect({
|
||||
required bool callStageEntered,
|
||||
required bool sessionConnected,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
pubspec.yaml
22
pubspec.yaml
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.54+143
|
||||
version: 1.3.58+147
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -175,14 +175,14 @@ flutter_intl:
|
||||
flutter_native_splash:
|
||||
android: true
|
||||
ios: true
|
||||
color: '#FFFFFF'
|
||||
image_android: 'assets/splash/splash.png'
|
||||
image_dark_android: 'assets/splash/splash.png'
|
||||
image_ios: 'assets/splash/splash.png'
|
||||
image_dark_ios: 'assets/splash/splash.png'
|
||||
branding_android: 'assets/splash/branding.png'
|
||||
branding_dark_android: 'assets/splash/branding.png'
|
||||
color: "#FFFFFF"
|
||||
image_android: "assets/splash/splash.png"
|
||||
image_dark_android: "assets/splash/splash.png"
|
||||
image_ios: "assets/splash/splash.png"
|
||||
image_dark_ios: "assets/splash/splash.png"
|
||||
branding_android: "assets/splash/branding.png"
|
||||
branding_dark_android: "assets/splash/branding.png"
|
||||
android_12:
|
||||
image: 'assets/splash/splash.png'
|
||||
image_dark: 'assets/splash/splash.png'
|
||||
branding: 'assets/splash/branding.png'
|
||||
image: "assets/splash/splash.png"
|
||||
image_dark: "assets/splash/splash.png"
|
||||
branding: "assets/splash/branding.png"
|
||||
|
||||
@@ -4,14 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PortionState enum', () {
|
||||
test('has all expected values', () {
|
||||
expect(PortionState.values.length, 7);
|
||||
expect(PortionState.values.length, 10);
|
||||
expect(PortionState.filledDiet, isNotNull);
|
||||
expect(PortionState.eatenFromDiet, isNotNull);
|
||||
expect(PortionState.eaten, isNotNull);
|
||||
expect(PortionState.eatenDeleted, isNotNull);
|
||||
expect(PortionState.eatenEdited, isNotNull);
|
||||
expect(PortionState.commented, isNotNull);
|
||||
expect(PortionState.commentedFromDiet, isNotNull);
|
||||
expect(PortionState.reviewedByUser, isNotNull);
|
||||
expect(PortionState.reviewedFromDiet, isNotNull);
|
||||
expect(PortionState.unknown, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PortionModel.fromJson', () {
|
||||
Map<String, dynamic> portionJson({required String state}) => {
|
||||
'id': 1,
|
||||
'product_nutritional_value_id': null,
|
||||
'product_calories_info': null,
|
||||
'product_name': 'Oatmeal',
|
||||
'quantity': 1,
|
||||
'portion': '100g',
|
||||
'from_diet': true,
|
||||
'state': state,
|
||||
};
|
||||
|
||||
test('parses commented_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'commented_from_diet'));
|
||||
|
||||
expect(model.portionState, PortionState.commentedFromDiet);
|
||||
});
|
||||
|
||||
test('parses reviewed_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'reviewed_from_diet'));
|
||||
|
||||
expect(model.portionState, PortionState.reviewedFromDiet);
|
||||
});
|
||||
|
||||
test('does not throw on unknown state from backend', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'some_future_state'));
|
||||
|
||||
expect(model.portionState, PortionState.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
@@ -82,6 +83,47 @@ void main() {
|
||||
expect(foreground.startCount, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'decline right after hang up does not notify the backend again',
|
||||
() async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
await manager.endCall();
|
||||
clearInteractions(mockRepository);
|
||||
|
||||
await manager.declineIncomingCall('test-uuid');
|
||||
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'startCall right after hang up does not request a new token',
|
||||
() async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
await manager.endCall();
|
||||
clearInteractions(mockRepository);
|
||||
|
||||
final started = await manager.startCall(conversationUuid: 'test-uuid');
|
||||
|
||||
expect(started, isFalse);
|
||||
verifyNever(mockRepository.getParticipantToken(any));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decline of a call that was never joined notifies the backend',
|
||||
() async {
|
||||
when(
|
||||
mockRepository.notifyCallEnded(any),
|
||||
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
|
||||
|
||||
await manager.declineIncomingCall('other-uuid');
|
||||
|
||||
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('endCall stops foreground service', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
|
||||
@@ -175,6 +175,18 @@ void main() {
|
||||
});
|
||||
|
||||
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
|
||||
final mockRoom = MockRoom();
|
||||
when(mockManager.room).thenReturn(mockRoom);
|
||||
when(
|
||||
mockManager.onCallScreenVisible(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
counterpartName: anyNamed('counterpartName'),
|
||||
localParticipantName: anyNamed('localParticipantName'),
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -199,7 +211,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -231,6 +243,7 @@ void main() {
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -262,7 +275,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -297,7 +310,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -328,7 +341,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -362,7 +375,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
@@ -390,7 +403,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
||||
expect(cubit.isCallScreenRouteVisible, isTrue);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const conversation = '11111111-1111-4111-8111-111111111111';
|
||||
const otherConversation = '22222222-2222-4222-8222-222222222222';
|
||||
const callKitChannel = MethodChannel('flutter_callkit_incoming');
|
||||
|
||||
group('VideoCallEndedPushHandler.callMatchesConversation', () {
|
||||
test('matches the conversation stored as the call handle', () {
|
||||
@@ -32,10 +38,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('leaves a call from another chat alone', () {
|
||||
const call = CallKitParams(
|
||||
id: 'other-id',
|
||||
handle: '22222222-2222-4222-8222-222222222222',
|
||||
);
|
||||
const call = CallKitParams(id: 'other-id', handle: otherConversation);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
|
||||
@@ -43,4 +46,62 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||
final calls = <MethodCall>[];
|
||||
late List<Map<String, Object?>> activeCalls;
|
||||
|
||||
setUp(() {
|
||||
calls.clear();
|
||||
VideoCallRemoteEndRegistry.clear();
|
||||
activeCalls = <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'id': 'ringing-id',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{'conversationUuid': conversation},
|
||||
},
|
||||
<String, Object?>{
|
||||
'id': 'other-id',
|
||||
'handle': otherConversation,
|
||||
'extra': <String, Object?>{'conversationUuid': otherConversation},
|
||||
},
|
||||
];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(callKitChannel, (call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'activeCalls') return activeCalls;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(callKitChannel, null);
|
||||
});
|
||||
|
||||
test('ends only the matching call and marks it as a remote end', () async {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: conversation,
|
||||
);
|
||||
|
||||
final ended = calls
|
||||
.where((call) => call.method == 'endCall')
|
||||
.map((call) => (call.arguments as Map)['id'])
|
||||
.toList();
|
||||
expect(ended, <String>['ringing-id']);
|
||||
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'without a conversation ends every call and marks all of them',
|
||||
() async {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
||||
|
||||
expect(calls.map((call) => call.method), contains('endAllCalls'));
|
||||
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +74,16 @@ void main() {
|
||||
expect(shown.single.ios?.configureAudioSession, isFalse);
|
||||
expect(shown.single.ios?.audioSessionActive, isFalse);
|
||||
expect(shown.single.ios?.audioSessionMode, 'videoChat');
|
||||
expect(shown.single.ios?.ringtonePath, 'system_ringtone_default');
|
||||
expect(shown.single.ios?.ringtonePath, isNull);
|
||||
expect(shown.single.missedCallNotification?.showNotification, isTrue);
|
||||
expect(
|
||||
shown.single.missedCallNotification?.subtitle,
|
||||
VideoCallIncomingPushPresenter.iosMissedCallSubtitle,
|
||||
);
|
||||
expect(
|
||||
shown.single.missedCallNotification?.callbackText,
|
||||
VideoCallIncomingPushPresenter.iosMissedCallCallbackText,
|
||||
);
|
||||
});
|
||||
|
||||
test('iOS stops polling when the push claims the call', () async {
|
||||
@@ -122,6 +131,25 @@ void main() {
|
||||
expect(shown, isEmpty);
|
||||
});
|
||||
|
||||
test('Android does not open a second CallKit UI', () async {
|
||||
final shown = <CallKitParams>[];
|
||||
final native = <VideoCallPushPayload>[];
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: false,
|
||||
showAndroidIncoming: (incoming) async {
|
||||
native.add(incoming);
|
||||
},
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
);
|
||||
|
||||
expect(shown, isEmpty);
|
||||
expect(native, [payload]);
|
||||
});
|
||||
|
||||
test(
|
||||
'Android shows the incoming call without waiting for a VoIP claim',
|
||||
() async {
|
||||
|
||||
@@ -96,6 +96,92 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('a decline just after leaving does not report the end again', () {
|
||||
final leftAt = DateTime(2026, 10, 16, 12);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 5)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(incomingDeclineNotifySuppression),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(leftAt: null, now: leftAt),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a token is not requested again right after leaving', () {
|
||||
final leftAt = DateTime(2026, 10, 16, 12);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: false,
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 2)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: true,
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 2)),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: false,
|
||||
leftAt: null,
|
||||
now: leftAt,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'an ended VoIP push is marked so its CallKit close is not a decline',
|
||||
() {
|
||||
expect(
|
||||
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': true}),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': 'true'}),
|
||||
isTrue,
|
||||
);
|
||||
expect(callKitExtraMarksEndedPush(<String, dynamic>{}), isFalse);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{},
|
||||
eventExtra: <String, dynamic>{'endedPush': true},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{'endedPush': true},
|
||||
eventExtra: <String, dynamic>{},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{},
|
||||
eventExtra: <String, dynamic>{},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps a connection-loss screen when the link drops', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterRoomDisconnect(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user