forked from eatvio/eatvio_app
merege from main + version bump
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
@@ -167,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)
|
||||
@@ -239,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) {
|
||||
@@ -252,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()
|
||||
@@ -267,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,12 +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,
|
||||
@@ -699,8 +749,15 @@ 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.
|
||||
@@ -736,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)
|
||||
@@ -757,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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses
|
||||
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';
|
||||
@@ -375,7 +376,7 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _handleEnded(id);
|
||||
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||
}
|
||||
|
||||
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||
@@ -873,10 +874,24 @@ class VideoCallCallKitCoordinator {
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final endedPushDismissal = callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
eventExtra: eventExtra,
|
||||
);
|
||||
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 {
|
||||
@@ -888,12 +903,7 @@ class VideoCallCallKitCoordinator {
|
||||
}),
|
||||
);
|
||||
}
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
}
|
||||
|
||||
if (endedPushDismissal) return;
|
||||
_forgetCallKitId(callKitId);
|
||||
|
||||
if (_manager.isActiveCall ||
|
||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||
@@ -907,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',
|
||||
@@ -916,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 (_) {}
|
||||
|
||||
@@ -20,6 +20,10 @@ typedef VideoCallAndroidIncomingShow =
|
||||
/// 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',
|
||||
);
|
||||
@@ -97,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.57+146
|
||||
version: 1.3.59+148
|
||||
publish_to: "none"
|
||||
|
||||
environment:
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ void main() {
|
||||
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 {
|
||||
|
||||
@@ -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