forked from eatvio/eatvio_app
Merge pull request 'feat(video_calls): screen waking up' (#1083) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1083
This commit is contained in:
@@ -87,8 +87,11 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
<!-- The incoming notification's content intent and full-screen intent both open
|
||||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||||
|
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||||
|
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||||
|
CLEAR_TASK flag from killing MainActivity. -->
|
||||||
<activity
|
<activity
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
@@ -97,20 +100,26 @@
|
|||||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation" />
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation"
|
android:configChanges="orientation"
|
||||||
android:permission="${applicationId}.PERMISSION_CALL">
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1,12 +1,71 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
|
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
|
||||||
|
import com.hiennv.flutter_callkit_incoming.Utils
|
||||||
|
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import kotlin.math.abs
|
||||||
|
import com.hiennv.flutter_callkit_incoming.R as CallkitR
|
||||||
|
|
||||||
/// Redirects notification body / full-screen intent taps to the same accept path as
|
/// Target of the incoming notification's content intent and full-screen intent.
|
||||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
///
|
||||||
|
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
|
||||||
|
/// accept flow as the Answer button.
|
||||||
|
///
|
||||||
|
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
|
||||||
|
/// ringing screen that wakes the device and lets the user answer or decline.
|
||||||
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EatvioIncomingBridge"
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
|
||||||
|
private const val DEFAULT_RING_DURATION_MS = 45_000L
|
||||||
|
private const val MIN_RING_SCREEN_MS = 1_000L
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the ringing screen once the call stopped ringing: answered or declined
|
||||||
|
* from the notification, timed out, or ended by the caller.
|
||||||
|
*/
|
||||||
|
fun dismissRingingScreen() {
|
||||||
|
mainHandler.post {
|
||||||
|
ringingScreen?.get()?.closeRingingScreen()
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
|
||||||
|
return keyguardLocked || !screenInteractive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callData: Bundle? = null
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var ringTimeout: Runnable? = null
|
||||||
|
private var actionTaken = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
callData = data
|
||||||
|
|
||||||
CallKitIntentBridge.dispatchCallKitAction(
|
if (!launchedOnLockedOrDarkScreen()) {
|
||||||
this,
|
Log.i(TAG, "tap on notification body: accept")
|
||||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
data,
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
|
||||||
|
showRingingScreen(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
|
||||||
|
if (ringingScreen?.get() !== this) return
|
||||||
|
callData = data
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
cancelRingTimeout()
|
||||||
|
releaseWakeLock()
|
||||||
|
if (ringingScreen?.get() === this) {
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onBackPressed() {
|
||||||
|
// The ringing screen is dismissed by Answer / Decline / timeout only.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchedOnLockedOrDarkScreen(): Boolean {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return shouldShowRingingScreen(
|
||||||
|
keyguardLocked = keyguardManager.isKeyguardLocked,
|
||||||
|
screenInteractive = powerManager.isInteractive,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRingingScreen(data: Bundle) {
|
||||||
|
ringingScreen = WeakReference(this)
|
||||||
|
|
||||||
|
setTheme(CallkitR.style.CallkitIncomingTheme)
|
||||||
|
requestedOrientation = if (Utils.isTablet(this)) {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
} else {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
|
||||||
|
acquireWakeLock(ringDuration(data))
|
||||||
|
VideoCallPushBridge.keepRingingOnFullScreen()
|
||||||
|
|
||||||
|
setContentView(CallkitR.layout.activity_callkit_incoming)
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCallData(data: Bundle) {
|
||||||
|
val background: ImageView = findViewById(CallkitR.id.ivBackground)
|
||||||
|
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
|
||||||
|
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
|
||||||
|
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
|
||||||
|
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
|
||||||
|
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
|
||||||
|
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
|
||||||
|
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
|
||||||
|
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
|
||||||
|
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
|
||||||
|
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
|
||||||
|
|
||||||
|
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
|
||||||
|
ripple.startRippleAnimation()
|
||||||
|
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
|
||||||
|
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
|
||||||
|
actions.layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
|
||||||
|
callerNumber.visibility = View.INVISIBLE
|
||||||
|
logo.visibility = View.INVISIBLE
|
||||||
|
|
||||||
|
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
|
||||||
|
callerName.setTextColor(textColor)
|
||||||
|
acceptLabel.setTextColor(textColor)
|
||||||
|
declineLabel.setTextColor(textColor)
|
||||||
|
background.setBackgroundColor(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
|
||||||
)
|
)
|
||||||
|
|
||||||
finish()
|
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||||
@Suppress("DEPRECATION")
|
if (!avatarUrl.isNullOrEmpty()) {
|
||||||
overridePendingTransition(0, 0)
|
avatar.visibility = View.VISIBLE
|
||||||
|
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||||
|
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
|
||||||
|
try {
|
||||||
|
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to load caller avatar", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
|
||||||
|
acceptButton.setImageResource(CallkitR.drawable.ic_video)
|
||||||
|
}
|
||||||
|
acceptButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
|
||||||
|
)
|
||||||
|
declineButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
|
||||||
|
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
|
||||||
|
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
|
||||||
|
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
|
||||||
|
|
||||||
|
acceptButton.setOnClickListener { onAcceptClick() }
|
||||||
|
declineButton.setOnClickListener { onDeclineClick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAcceptClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: accept")
|
||||||
|
|
||||||
|
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
|
||||||
|
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
|
||||||
|
// becomes visible as soon as the user unlocks.
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
if (keyguardManager.isKeyguardLocked) {
|
||||||
|
keyguardManager.requestDismissKeyguard(this, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeclineClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: decline")
|
||||||
|
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeRingingScreen() {
|
||||||
|
if (isFinishing) return
|
||||||
|
cancelRingTimeout()
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishRingingTask() {
|
||||||
|
if (isFinishing) return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
finishAndRemoveTask()
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ringDuration(data: Bundle): Long {
|
||||||
|
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
|
||||||
|
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification was posted before this screen appeared, so ring only for the
|
||||||
|
* remaining part of the call duration.
|
||||||
|
*/
|
||||||
|
private fun scheduleRingTimeout(data: Bundle) {
|
||||||
|
cancelRingTimeout()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
|
||||||
|
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
|
||||||
|
val timeout = Runnable {
|
||||||
|
Log.i(TAG, "ringing screen: timed out")
|
||||||
|
closeRingingScreen()
|
||||||
|
}
|
||||||
|
ringTimeout = timeout
|
||||||
|
mainHandler.postDelayed(timeout, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingTimeout() {
|
||||||
|
ringTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun acquireWakeLock(durationMs: Long) {
|
||||||
|
try {
|
||||||
|
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val lock = powerManager.newWakeLock(
|
||||||
|
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
PowerManager.ON_AFTER_RELEASE,
|
||||||
|
WAKE_LOCK_TAG,
|
||||||
|
)
|
||||||
|
lock.acquire(durationMs)
|
||||||
|
wakeLock = lock
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to acquire wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
val lock = wakeLock ?: return
|
||||||
|
wakeLock = null
|
||||||
|
try {
|
||||||
|
if (lock.isHeld) lock.release()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to release wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(value: String?, fallback: Int): Int {
|
||||||
|
if (value.isNullOrBlank()) return fallback
|
||||||
|
return try {
|
||||||
|
Color.parseColor(value)
|
||||||
|
} catch (error: IllegalArgumentException) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
@@ -24,6 +25,7 @@ object VideoCallPushBridge {
|
|||||||
private const val TAG = "VideoCallPushBridge"
|
private const val TAG = "VideoCallPushBridge"
|
||||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private val playbackLock = Any()
|
private val playbackLock = Any()
|
||||||
@@ -34,6 +36,7 @@ object VideoCallPushBridge {
|
|||||||
private var activeCallBundle: Bundle? = null
|
private var activeCallBundle: Bundle? = null
|
||||||
private var storedAppContext: Context? = null
|
private var storedAppContext: Context? = null
|
||||||
private var ringtoneTimeout: Runnable? = null
|
private var ringtoneTimeout: Runnable? = null
|
||||||
|
private var ringWakeLock: PowerManager.WakeLock? = null
|
||||||
private var callbackRegistered = false
|
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) {
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
val calls = getDataActiveCalls(appContext)
|
val calls = getDataActiveCalls(appContext)
|
||||||
@@ -239,6 +252,7 @@ object VideoCallPushBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||||
|
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
|
||||||
if (durationMs > 0L) {
|
if (durationMs > 0L) {
|
||||||
val timeout = Runnable {
|
val timeout = Runnable {
|
||||||
synchronized(playbackLock) {
|
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) {
|
private fun stopPlaybackLocked(appContext: Context) {
|
||||||
cancelRingtoneTimeoutLocked()
|
cancelRingtoneTimeoutLocked()
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
val notificationManager = activeNotificationManager
|
val notificationManager = activeNotificationManager
|
||||||
val bundle = activeCallBundle
|
val bundle = activeCallBundle
|
||||||
stopIncomingRingtone()
|
stopIncomingRingtone()
|
||||||
@@ -267,6 +316,7 @@ object VideoCallPushBridge {
|
|||||||
activeSoundManager = null
|
activeSoundManager = null
|
||||||
activeNotificationManager = null
|
activeNotificationManager = null
|
||||||
activeCallBundle = null
|
activeCallBundle = null
|
||||||
|
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelRingtoneTimeoutLocked() {
|
private fun cancelRingtoneTimeoutLocked() {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import flutter_foreground_task
|
|||||||
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||||
|
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
|
||||||
|
private let pendingAcceptedCallTtl: TimeInterval = 120
|
||||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||||
private let appUrlScheme = "eatvio"
|
private let appUrlScheme = "eatvio"
|
||||||
@@ -234,6 +236,26 @@ import flutter_foreground_task
|
|||||||
return nil
|
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? {
|
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||||
guard
|
guard
|
||||||
let actionUrl = actionUrl,
|
let actionUrl = actionUrl,
|
||||||
@@ -282,16 +304,31 @@ import flutter_foreground_task
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func storePendingAcceptedCall(_ call: Call) {
|
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)
|
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]? {
|
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
|
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
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +552,13 @@ import flutter_foreground_task
|
|||||||
handle: conversation,
|
handle: conversation,
|
||||||
type: 1
|
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)
|
plugin.endCall(endData)
|
||||||
releaseIncomingCallClaim(id)
|
releaseIncomingCallClaim(id)
|
||||||
}
|
}
|
||||||
@@ -686,12 +730,18 @@ import flutter_foreground_task
|
|||||||
} else if !nameCaller.isEmpty {
|
} else if !nameCaller.isEmpty {
|
||||||
extra["counterpartName"] = nameCaller
|
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
|
// 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
|
// backend, or the server sends another ended VoIP and the other phone
|
||||||
// shows it as a new incoming call.
|
// shows it as a new incoming call.
|
||||||
if isEndedPush {
|
if isEndedPush {
|
||||||
extra["endedPush"] = true
|
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(
|
let data = flutter_callkit_incoming.Data(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -699,8 +749,15 @@ import flutter_foreground_task
|
|||||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||||
type: isVideo ? 1 : 0
|
type: isVideo ? 1 : 0
|
||||||
)
|
)
|
||||||
|
data.appName = "Итвио"
|
||||||
|
data.handleType = "generic"
|
||||||
|
data.supportsVideo = true
|
||||||
data.duration = 45000
|
data.duration = 45000
|
||||||
data.extra = extra as NSDictionary
|
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
|
// The plugin activates AVAudioSession itself on answer and again 1.2s
|
||||||
// later. That drops the ringing UI and leaves one side silent. CallKit
|
// later. That drops the ringing UI and leaves one side silent. CallKit
|
||||||
// and WebRTC own the session instead.
|
// and WebRTC own the session instead.
|
||||||
@@ -736,18 +793,25 @@ import flutter_foreground_task
|
|||||||
callKitIdSourceName: callKitIdSourceName,
|
callKitIdSourceName: callKitIdSourceName,
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
endAfterReport: isEndedPush,
|
endAfterReport: isEndedPush,
|
||||||
|
endImmediately: isPhantomEndedReport,
|
||||||
completion: completion
|
completion: completion
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
||||||
/// its completion handler runs. Both happen here, including an "ended" push.
|
/// 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(
|
private func reportVoipPushToCallKit(
|
||||||
_ data: flutter_callkit_incoming.Data,
|
_ data: flutter_callkit_incoming.Data,
|
||||||
callKitId: String,
|
callKitId: String,
|
||||||
callKitIdSourceName: String,
|
callKitIdSourceName: String,
|
||||||
conversationUuid: String,
|
conversationUuid: String,
|
||||||
endAfterReport: Bool,
|
endAfterReport: Bool,
|
||||||
|
endImmediately: Bool,
|
||||||
completion: @escaping () -> Void
|
completion: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
let gate = VoipPushCompletion(completion)
|
let gate = VoipPushCompletion(completion)
|
||||||
@@ -757,6 +821,12 @@ import flutter_foreground_task
|
|||||||
}
|
}
|
||||||
|
|
||||||
let afterReport = {
|
let afterReport = {
|
||||||
|
if endAfterReport, endImmediately,
|
||||||
|
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
|
||||||
|
{
|
||||||
|
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
|
||||||
|
plugin.saveEndCall(callKitId, 6)
|
||||||
|
}
|
||||||
if endAfterReport {
|
if endAfterReport {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.endCallsForEndedPush(
|
self.endCallsForEndedPush(
|
||||||
|
|||||||
@@ -69,13 +69,13 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
await _requestNotificationPermission();
|
await _requestNotificationPermission();
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
final credentials = await _resolvePushCredentials();
|
final credentials = await _resolvePushCredentials();
|
||||||
final token = credentials.token;
|
final token = credentials.token;
|
||||||
if (token == null || token.isEmpty) return;
|
if (token != null && token.isNotEmpty) {
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/register',
|
'/api/profile/notification/token/register',
|
||||||
data: {
|
data: {
|
||||||
@@ -89,14 +89,17 @@ class FirebaseApi {
|
|||||||
throw TimeoutException('registerPushToken timeout');
|
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_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_peer_departure.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||||
@@ -375,7 +376,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _handleEnded(id);
|
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||||
@@ -873,10 +874,24 @@ class VideoCallCallKitCoordinator {
|
|||||||
extra,
|
extra,
|
||||||
handle: params?.handle ?? callKitId,
|
handle: params?.handle ?? callKitId,
|
||||||
);
|
);
|
||||||
final endedPushDismissal = callKitDeclineIsEndedPush(
|
final remoteEndDismissal =
|
||||||
cachedExtra: params?.extra,
|
callKitDeclineIsEndedPush(
|
||||||
eventExtra: eventExtra,
|
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);
|
_suppressedEndCallKitIds.add(callKitId);
|
||||||
try {
|
try {
|
||||||
@@ -888,12 +903,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_callKitParamsById.remove(callKitId);
|
_forgetCallKitId(callKitId);
|
||||||
if (_activeCallKitId == callKitId) {
|
|
||||||
_activeCallKitId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endedPushDismissal) return;
|
|
||||||
|
|
||||||
if (_manager.isActiveCall ||
|
if (_manager.isActiveCall ||
|
||||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
_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)) {
|
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||||
log(
|
log(
|
||||||
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
||||||
@@ -916,16 +929,36 @@ class VideoCallCallKitCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_callKitParamsById.remove(callKitId);
|
// iOS reports an ended VoIP push to CallKit and closes it right away.
|
||||||
if (_activeCallKitId == callKitId) {
|
// That close is marked and must be remembered before the decline (or the
|
||||||
_activeCallKitId = null;
|
// later timeout) event for the same id arrives with no marker.
|
||||||
|
final remoteEnd = callKitDeclineIsEndedPush(
|
||||||
|
cachedExtra: _paramsForCallId(callKitId)?.extra,
|
||||||
|
eventExtra: eventExtra,
|
||||||
|
);
|
||||||
|
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 {
|
try {
|
||||||
await FlutterCallkitIncoming.endCall(callKitId);
|
await FlutterCallkitIncoming.endCall(callKitId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _forgetCallKitId(String callKitId) {
|
||||||
|
_callKitParamsById.remove(callKitId);
|
||||||
|
if (_activeCallKitId == callKitId) {
|
||||||
|
_activeCallKitId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String _parseConversationUuid(
|
static String _parseConversationUuid(
|
||||||
Map<String, Object?> extra, {
|
Map<String, Object?> extra, {
|
||||||
String? handle,
|
String? handle,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
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/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/services.dart';
|
||||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
@@ -53,16 +54,23 @@ abstract final class VideoCallEndedPushHandler {
|
|||||||
return false;
|
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 {
|
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
|
||||||
final uuid = conversationUuid?.trim();
|
final uuid = conversationUuid?.trim();
|
||||||
try {
|
try {
|
||||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||||
if (uuid == null || uuid.isEmpty) {
|
if (uuid == null || uuid.isEmpty) {
|
||||||
|
for (final call in activeCalls) {
|
||||||
|
VideoCallRemoteEndRegistry.mark(call.id);
|
||||||
|
}
|
||||||
await FlutterCallkitIncoming.endAllCalls();
|
await FlutterCallkitIncoming.endAllCalls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (final call in activeCalls) {
|
for (final call in activeCalls) {
|
||||||
if (!callMatchesConversation(call, uuid)) continue;
|
if (!callMatchesConversation(call, uuid)) continue;
|
||||||
|
VideoCallRemoteEndRegistry.mark(call.id);
|
||||||
try {
|
try {
|
||||||
await FlutterCallkitIncoming.endCall(call.id);
|
await FlutterCallkitIncoming.endCall(call.id);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ typedef VideoCallAndroidIncomingShow =
|
|||||||
/// never arrived.
|
/// never arrived.
|
||||||
abstract final class VideoCallIncomingPushPresenter {
|
abstract final class VideoCallIncomingPushPresenter {
|
||||||
static const String _androidAccentColor = '#2787F5';
|
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(
|
static const MethodChannel _voipChannel = MethodChannel(
|
||||||
'com.eatvioapp/voip_diagnostics',
|
'com.eatvioapp/voip_diagnostics',
|
||||||
);
|
);
|
||||||
@@ -97,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||||
},
|
},
|
||||||
callingNotification: const NotificationParams(showNotification: false),
|
callingNotification: const NotificationParams(showNotification: false),
|
||||||
|
missedCallNotification: const NotificationParams(
|
||||||
|
showNotification: true,
|
||||||
|
subtitle: iosMissedCallSubtitle,
|
||||||
|
callbackText: iosMissedCallCallbackText,
|
||||||
|
),
|
||||||
android: const AndroidParams(
|
android: const AndroidParams(
|
||||||
isCustomNotification: true,
|
isCustomNotification: true,
|
||||||
isShowLogo: false,
|
isShowLogo: false,
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.56+145
|
version: 1.3.58+147
|
||||||
publish_to: "none"
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
@@ -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_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_callkit_incoming/entities/call_kit_params.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
const conversation = '11111111-1111-4111-8111-111111111111';
|
const conversation = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const otherConversation = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const callKitChannel = MethodChannel('flutter_callkit_incoming');
|
||||||
|
|
||||||
group('VideoCallEndedPushHandler.callMatchesConversation', () {
|
group('VideoCallEndedPushHandler.callMatchesConversation', () {
|
||||||
test('matches the conversation stored as the call handle', () {
|
test('matches the conversation stored as the call handle', () {
|
||||||
@@ -32,10 +38,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('leaves a call from another chat alone', () {
|
test('leaves a call from another chat alone', () {
|
||||||
const call = CallKitParams(
|
const call = CallKitParams(id: 'other-id', handle: otherConversation);
|
||||||
id: 'other-id',
|
|
||||||
handle: '22222222-2222-4222-8222-222222222222',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
|
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?.audioSessionActive, isFalse);
|
||||||
expect(shown.single.ios?.audioSessionMode, 'videoChat');
|
expect(shown.single.ios?.audioSessionMode, 'videoChat');
|
||||||
expect(shown.single.ios?.ringtonePath, isNull);
|
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 {
|
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