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:
2026-10-19 13:15:30 +00:00
13 changed files with 683 additions and 50 deletions

View File

@@ -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>

View File

@@ -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
}
} }
} }

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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();
} }
} }

View File

@@ -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,

View File

@@ -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 (_) {}

View File

@@ -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,

View File

@@ -0,0 +1,37 @@
/// CallKit ids that were closed because the call ended remotely.
///
/// On iOS every close of a ringing call comes back from the plugin as a
/// decline, whether the user tapped Decline or the app ended the call after a
/// `video_call_ended` signal. Only a decline by the user may be reported to
/// the backend; the other kind would make the server send one more ended
/// push and the other phone would show it as a new incoming call.
abstract final class VideoCallRemoteEndRegistry {
/// Longer than the 45 s ring timeout: the plugin still emits a timeout
/// event for a call it reported and closed right away.
static const Duration ttl = Duration(minutes: 2);
static final Map<String, DateTime> _markedAt = <String, DateTime>{};
static void mark(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return;
final at = now ?? DateTime.now();
_prune(at);
_markedAt[id] = at;
}
static bool contains(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return false;
_prune(now ?? DateTime.now());
return _markedAt.containsKey(id);
}
static void clear() => _markedAt.clear();
static String _normalize(String callKitId) => callKitId.trim().toLowerCase();
static void _prune(DateTime now) {
_markedAt.removeWhere((_, markedAt) => now.difference(markedAt) >= ttl);
}
}

View File

@@ -1,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

View File

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

View File

@@ -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 {

View File

@@ -0,0 +1,44 @@
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(VideoCallRemoteEndRegistry.clear);
group('VideoCallRemoteEndRegistry', () {
test('remembers a marked id regardless of case and whitespace', () {
VideoCallRemoteEndRegistry.mark(' 11111111-1111-4111-8111-111111111111 ');
expect(
VideoCallRemoteEndRegistry.contains(
'11111111-1111-4111-8111-111111111111'.toUpperCase(),
),
isTrue,
);
});
test('does not know an id that was never marked', () {
expect(VideoCallRemoteEndRegistry.contains('other'), isFalse);
expect(VideoCallRemoteEndRegistry.contains(''), isFalse);
});
test('outlives the 45 s ring timeout and expires after the ttl', () {
final markedAt = DateTime(2026, 10, 19, 12);
VideoCallRemoteEndRegistry.mark('call', now: markedAt);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(const Duration(seconds: 50)),
),
isTrue,
);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(VideoCallRemoteEndRegistry.ttl),
),
isFalse,
);
});
});
}