diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3e73e6bf..c2b6f6e2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -87,8 +87,11 @@
android:name="flutterEmbedding"
android:value="2" />
-
+
@@ -97,20 +100,26 @@
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
+ android:taskAffinity=""
+ android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:noHistory="true"
+ android:showWhenLocked="true"
android:turnScreenOn="true"
- android:launchMode="singleTop"
+ android:launchMode="singleInstance"
android:configChanges="orientation" />
diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt
index 965e2af0..503e05e8 100644
--- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt
+++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt
@@ -1,12 +1,71 @@
package com.eatvioapp.app
import android.app.Activity
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.graphics.Color
+import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.PowerManager
+import android.util.Log
+import android.view.View
+import android.view.WindowManager
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitConstants
+import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
+import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
+import com.hiennv.flutter_callkit_incoming.Utils
+import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
+import java.lang.ref.WeakReference
+import kotlin.math.abs
+import com.hiennv.flutter_callkit_incoming.R as CallkitR
-/// Redirects notification body / full-screen intent taps to the same accept path as
-/// the Answer button, instead of showing the native Callkit incoming UI.
+/// Target of the incoming notification's content intent and full-screen intent.
+///
+/// Unlocked, lit screen: the user tapped the notification body, route it to the same
+/// accept flow as the Answer button.
+///
+/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
+/// ringing screen that wakes the device and lets the user answer or decline.
class EatvioCallkitIncomingBridgeActivity : Activity() {
+ companion object {
+ private const val TAG = "EatvioIncomingBridge"
+ private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
+ private const val DEFAULT_RING_DURATION_MS = 45_000L
+ private const val MIN_RING_SCREEN_MS = 1_000L
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var ringingScreen: WeakReference? = null
+
+ /**
+ * Closes the ringing screen once the call stopped ringing: answered or declined
+ * from the notification, timed out, or ended by the caller.
+ */
+ fun dismissRingingScreen() {
+ mainHandler.post {
+ ringingScreen?.get()?.closeRingingScreen()
+ ringingScreen = null
+ }
+ }
+
+ fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
+ return keyguardLocked || !screenInteractive
+ }
+ }
+
+ private var callData: Bundle? = null
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var ringTimeout: Runnable? = null
+ private var actionTaken = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
finish()
return
}
+ callData = data
- CallKitIntentBridge.dispatchCallKitAction(
- this,
- CallkitConstants.ACTION_CALL_ACCEPT,
- data,
+ if (!launchedOnLockedOrDarkScreen()) {
+ Log.i(TAG, "tap on notification body: accept")
+ CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
+ finish()
+ @Suppress("DEPRECATION")
+ overridePendingTransition(0, 0)
+ return
+ }
+
+ Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
+ showRingingScreen(data)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
+ if (ringingScreen?.get() !== this) return
+ callData = data
+ bindCallData(data)
+ scheduleRingTimeout(data)
+ }
+
+ override fun onDestroy() {
+ cancelRingTimeout()
+ releaseWakeLock()
+ if (ringingScreen?.get() === this) {
+ ringingScreen = null
+ }
+ super.onDestroy()
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onBackPressed() {
+ // The ringing screen is dismissed by Answer / Decline / timeout only.
+ }
+
+ private fun launchedOnLockedOrDarkScreen(): Boolean {
+ val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+ return shouldShowRingingScreen(
+ keyguardLocked = keyguardManager.isKeyguardLocked,
+ screenInteractive = powerManager.isInteractive,
+ )
+ }
+
+ private fun showRingingScreen(data: Bundle) {
+ ringingScreen = WeakReference(this)
+
+ setTheme(CallkitR.style.CallkitIncomingTheme)
+ requestedOrientation = if (Utils.isTablet(this)) {
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ } else {
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setTurnScreenOn(true)
+ setShowWhenLocked(true)
+ } else {
+ @Suppress("DEPRECATION")
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
+ )
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
+ }
+
+ acquireWakeLock(ringDuration(data))
+ VideoCallPushBridge.keepRingingOnFullScreen()
+
+ setContentView(CallkitR.layout.activity_callkit_incoming)
+ bindCallData(data)
+ scheduleRingTimeout(data)
+ }
+
+ private fun bindCallData(data: Bundle) {
+ val background: ImageView = findViewById(CallkitR.id.ivBackground)
+ val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
+ val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
+ val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
+ val logo: ImageView = findViewById(CallkitR.id.ivLogo)
+ val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
+ val actions: LinearLayout = findViewById(CallkitR.id.llAction)
+ val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
+ val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
+ val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
+ val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
+
+ ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
+ ripple.startRippleAnimation()
+ (actions.layoutParams as? MarginLayoutParams)?.let { params ->
+ params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
+ actions.layoutParams = params
+ }
+
+ callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
+ callerNumber.visibility = View.INVISIBLE
+ logo.visibility = View.INVISIBLE
+
+ val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
+ callerName.setTextColor(textColor)
+ acceptLabel.setTextColor(textColor)
+ declineLabel.setTextColor(textColor)
+ background.setBackgroundColor(
+ parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
)
- finish()
- @Suppress("DEPRECATION")
- overridePendingTransition(0, 0)
+ val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
+ if (!avatarUrl.isNullOrEmpty()) {
+ avatar.visibility = View.VISIBLE
+ @Suppress("DEPRECATION", "UNCHECKED_CAST")
+ val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap
+ 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
+ }
}
}
diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt
index f4aa5333..50afdac6 100644
--- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt
+++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -24,6 +25,7 @@ object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
+ private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
@@ -34,6 +36,7 @@ object VideoCallPushBridge {
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = null
+ private var ringWakeLock: PowerManager.WakeLock? = null
private var callbackRegistered = false
/**
@@ -167,6 +170,16 @@ object VideoCallPushBridge {
}
}
+ /**
+ * The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
+ * the display on itself, so keep ringing through that transition.
+ */
+ fun keepRingingOnFullScreen() {
+ synchronized(playbackLock) {
+ activeSoundManager?.keepRingingOnFullScreen()
+ }
+ }
+
fun dismissAllIncomingCalls(context: Context) {
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
@@ -239,6 +252,7 @@ object VideoCallPushBridge {
}
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
+ wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
if (durationMs > 0L) {
val timeout = Runnable {
synchronized(playbackLock) {
@@ -252,8 +266,43 @@ object VideoCallPushBridge {
}
}
+ /**
+ * Turns the screen on while the call rings. The full-screen intent of the
+ * notification does the same only when the system grants it; the wake lock
+ * works without any grant, so the lock screen with the call card lights up.
+ */
+ @Suppress("DEPRECATION")
+ private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
+ releaseScreenWakeLockLocked()
+ try {
+ val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
+ val lock = powerManager.newWakeLock(
+ PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
+ PowerManager.ACQUIRE_CAUSES_WAKEUP or
+ PowerManager.ON_AFTER_RELEASE,
+ WAKE_LOCK_TAG,
+ )
+ lock.acquire(durationMs)
+ ringWakeLock = lock
+ Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
+ } catch (error: Exception) {
+ Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
+ }
+ }
+
+ private fun releaseScreenWakeLockLocked() {
+ val lock = ringWakeLock ?: return
+ ringWakeLock = null
+ try {
+ if (lock.isHeld) lock.release()
+ } catch (error: Exception) {
+ Log.w(TAG, "wakeScreen: failed to release wake lock", error)
+ }
+ }
+
private fun stopPlaybackLocked(appContext: Context) {
cancelRingtoneTimeoutLocked()
+ releaseScreenWakeLockLocked()
val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
@@ -267,6 +316,7 @@ object VideoCallPushBridge {
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
+ EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 8d3392a2..b340b48c 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -15,6 +15,8 @@ import flutter_foreground_task
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
+ private let pendingAcceptedAtKey = "eatvioAcceptedAt"
+ private let pendingAcceptedCallTtl: TimeInterval = 120
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private let appUrlScheme = "eatvio"
@@ -234,6 +236,26 @@ import flutter_foreground_task
return nil
}
+ private func voipBool(_ value: Any?) -> Bool? {
+ if let bool = value as? Bool {
+ return bool
+ }
+ if let number = value as? NSNumber {
+ return number.intValue != 0
+ }
+ if let text = value as? String {
+ switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "1", "true", "yes":
+ return true
+ case "0", "false", "no":
+ return false
+ default:
+ return nil
+ }
+ }
+ return nil
+ }
+
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -282,16 +304,31 @@ import flutter_foreground_task
}
private func storePendingAcceptedCall(_ call: Call) {
- let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
+ var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
+ payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
+ /// An accept that Flutter never consumed (the process died before the
+ /// scene opened) must not open the call screen on a later launch: that
+ /// screen requests a token, and the token request rings the other person.
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
- guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
+ guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
+ let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
+ if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
+ recordVoipDiagnostic(
+ "pending_accepted_call_expired",
+ data: [
+ "callKitId": payload["id"] as? String ?? "",
+ "ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
+ ]
+ )
+ return nil
+ }
return payload
}
@@ -515,6 +552,13 @@ import flutter_foreground_task
handle: conversation,
type: 1
)
+ // The plugin sends this payload with its "ended" event. Dart uses the
+ // marker to tell a remote end from a decline by the user.
+ var endExtra: [String: Any] = ["endedPush": true]
+ if !conversation.isEmpty {
+ endExtra["conversationUuid"] = conversation
+ }
+ endData.extra = endExtra as NSDictionary
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
@@ -686,12 +730,18 @@ import flutter_foreground_task
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
+ if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
+ extra["counterpartIsConsultant"] = isConsultant
+ }
// Closing this report looks like a user decline. Dart must not tell the
// backend, or the server sends another ended VoIP and the other phone
// shows it as a new incoming call.
if isEndedPush {
extra["endedPush"] = true
}
+ // An ended push with no ringing call still has to be reported. That
+ // report is closed at once, so it must not leave a trace anywhere.
+ let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation"
let data = flutter_callkit_incoming.Data(
id: id,
@@ -699,8 +749,15 @@ import flutter_foreground_task
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
+ data.appName = "Итвио"
+ data.handleType = "generic"
+ data.supportsVideo = true
data.duration = 45000
data.extra = extra as NSDictionary
+ data.missedNotificationSubtitle = "Пропущенный видеозвонок"
+ data.missedNotificationCallbackText = "Перезвонить"
+ data.isShowMissedCallNotification = !isEndedPush
+ data.includesCallsInRecents = !isPhantomEndedReport
// The plugin activates AVAudioSession itself on answer and again 1.2s
// later. That drops the ringing UI and leaves one side silent. CallKit
// and WebRTC own the session instead.
@@ -736,18 +793,25 @@ import flutter_foreground_task
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
+ endImmediately: isPhantomEndedReport,
completion: completion
)
}
/// PushKit kills the app unless every VoIP push is reported to CallKit and
/// its completion handler runs. Both happen here, including an "ended" push.
+ ///
+ /// `endImmediately` is for an ended push that matched no ringing call. The
+ /// report is closed inside its own completion, before the incoming UI has a
+ /// chance to show. An end transaction would take one more round trip and
+ /// would also reach `onDecline`, which resets the in-app audio restart.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
endAfterReport: Bool,
+ endImmediately: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
@@ -757,6 +821,12 @@ import flutter_foreground_task
}
let afterReport = {
+ if endAfterReport, endImmediately,
+ let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
+ {
+ // Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
+ plugin.saveEndCall(callKitId, 6)
+ }
if endAfterReport {
DispatchQueue.main.async {
self.endCallsForEndedPush(
diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart
index cf4496de..d18024b3 100644
--- a/lib/core/firebase_api.dart
+++ b/lib/core/firebase_api.dart
@@ -69,13 +69,13 @@ class FirebaseApi {
}
Future registerPushToken() async {
- if (await TokenStorage.haveTokenInStorage()) {
- try {
- await _requestNotificationPermission();
- final deviceInfo = DeviceInfoPlugin();
- final credentials = await _resolvePushCredentials();
- final token = credentials.token;
- if (token == null || token.isEmpty) return;
+ if (!await TokenStorage.haveTokenInStorage()) return;
+ try {
+ await _requestNotificationPermission();
+ final deviceInfo = DeviceInfoPlugin();
+ final credentials = await _resolvePushCredentials();
+ final token = credentials.token;
+ if (token != null && token.isNotEmpty) {
await sl().post(
'/api/profile/notification/token/register',
data: {
@@ -89,14 +89,17 @@ class FirebaseApi {
throw TimeoutException('registerPushToken timeout');
},
);
- if (Platform.isIOS) {
- await _registerIosVoipPushToken();
- }
- } catch (e, st) {
- log(e.toString(), stackTrace: st);
- await _errorReporter.report(e, st, origin: 'FirebaseApi');
- // Не пробрасываем ошибку дальше, так как это не критичная операция
}
+ } catch (e, st) {
+ log(e.toString(), stackTrace: st);
+ await _errorReporter.report(e, st, origin: 'FirebaseApi');
+ // Не пробрасываем ошибку дальше, так как это не критичная операция
+ }
+ // Incoming calls on iOS come only through the VoIP token. It does not
+ // depend on the FCM token, so a missing or failed FCM registration must
+ // not leave the device without calls.
+ if (Platform.isIOS) {
+ await _registerIosVoipPushToken();
}
}
diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart
index 523f41fa..f57b6745 100644
--- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart
+++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart
@@ -10,6 +10,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
+import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
@@ -375,7 +376,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
- await _handleEnded(id);
+ await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future _captureCallKitEventToSentry(CallEvent event) async {
@@ -873,10 +874,24 @@ class VideoCallCallKitCoordinator {
extra,
handle: params?.handle ?? callKitId,
);
- final endedPushDismissal = callKitDeclineIsEndedPush(
- cachedExtra: params?.extra,
- eventExtra: eventExtra,
- );
+ final remoteEndDismissal =
+ callKitDeclineIsEndedPush(
+ cachedExtra: params?.extra,
+ eventExtra: eventExtra,
+ ) ||
+ VideoCallRemoteEndRegistry.contains(callKitId);
+
+ if (remoteEndDismissal) {
+ // The call is already closed on the native side. Another endCall is
+ // not needed: while the plugin's push flag is set it would end the
+ // last reported call instead of this id, which may be a newer ring.
+ log(
+ 'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId',
+ name: 'VideoCallCallKit',
+ );
+ _forgetCallKitId(callKitId);
+ return;
+ }
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -888,12 +903,7 @@ class VideoCallCallKitCoordinator {
}),
);
}
- _callKitParamsById.remove(callKitId);
- if (_activeCallKitId == callKitId) {
- _activeCallKitId = null;
- }
-
- if (endedPushDismissal) return;
+ _forgetCallKitId(callKitId);
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
@@ -907,7 +917,10 @@ class VideoCallCallKitCoordinator {
}
}
- Future _handleEnded(String callKitId) async {
+ Future _handleEnded(
+ String callKitId, {
+ Map? eventExtra,
+ }) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
@@ -916,16 +929,36 @@ class VideoCallCallKitCoordinator {
return;
}
- _callKitParamsById.remove(callKitId);
- if (_activeCallKitId == callKitId) {
- _activeCallKitId = null;
+ // iOS reports an ended VoIP push to CallKit and closes it right away.
+ // That close is marked and must be remembered before the decline (or the
+ // later timeout) event for the same id arrives with no marker.
+ final remoteEnd = callKitDeclineIsEndedPush(
+ cachedExtra: _paramsForCallId(callKitId)?.extra,
+ eventExtra: eventExtra,
+ );
+ if (remoteEnd) {
+ VideoCallRemoteEndRegistry.mark(callKitId);
}
+ _forgetCallKitId(callKitId);
+
+ // Native already ends a remote-ended call. Calling endCall again here
+ // overwrites the plugin's stored payload, so the decline that follows
+ // would lose the marker.
+ if (remoteEnd) return;
+
try {
await FlutterCallkitIncoming.endCall(callKitId);
} catch (_) {}
}
+ void _forgetCallKitId(String callKitId) {
+ _callKitParamsById.remove(callKitId);
+ if (_activeCallKitId == callKitId) {
+ _activeCallKitId = null;
+ }
+ }
+
static String _parseConversationUuid(
Map extra, {
String? handle,
diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart
index 5da0b1ca..9f50cc39 100644
--- a/lib/features/video_calls/services/video_call_ended_push_handler.dart
+++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
+import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -53,16 +54,23 @@ abstract final class VideoCallEndedPushHandler {
return false;
}
+ /// The close of a ringing call comes back from the plugin as a decline.
+ /// Each id is marked first, so the coordinator does not report that
+ /// decline to the backend as if the user had rejected the call.
static Future _dismissViaCallKit(String? conversationUuid) async {
final uuid = conversationUuid?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
+ for (final call in activeCalls) {
+ VideoCallRemoteEndRegistry.mark(call.id);
+ }
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
+ VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart
index f8ae7cef..1751ebc8 100644
--- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart
+++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart
@@ -20,6 +20,10 @@ typedef VideoCallAndroidIncomingShow =
/// never arrived.
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
+
+ /// Same texts as the native VoIP path in `AppDelegate.swift`.
+ static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок';
+ static const String iosMissedCallCallbackText = 'Перезвонить';
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
@@ -97,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
'counterpartIsConsultant': payload.counterpartIsConsultant,
},
callingNotification: const NotificationParams(showNotification: false),
+ missedCallNotification: const NotificationParams(
+ showNotification: true,
+ subtitle: iosMissedCallSubtitle,
+ callbackText: iosMissedCallCallbackText,
+ ),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
diff --git a/lib/features/video_calls/services/video_call_remote_end_registry.dart b/lib/features/video_calls/services/video_call_remote_end_registry.dart
new file mode 100644
index 00000000..47a423db
--- /dev/null
+++ b/lib/features/video_calls/services/video_call_remote_end_registry.dart
@@ -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 _markedAt = {};
+
+ 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);
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index f9269c80..d5782df6 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: eatvio_app
description: A new Flutter project.
-version: 1.3.56+145
-publish_to: "none"
+version: 1.3.58+147
+publish_to: 'none'
environment:
sdk: ^3.10.4
diff --git a/test/features/video_calls/services/video_call_ended_push_handler_test.dart b/test/features/video_calls/services/video_call_ended_push_handler_test.dart
index 62bbe6a8..90df2f1a 100644
--- a/test/features/video_calls/services/video_call_ended_push_handler_test.dart
+++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart
@@ -1,9 +1,15 @@
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
+import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
const conversation = '11111111-1111-4111-8111-111111111111';
+ const otherConversation = '22222222-2222-4222-8222-222222222222';
+ const callKitChannel = MethodChannel('flutter_callkit_incoming');
group('VideoCallEndedPushHandler.callMatchesConversation', () {
test('matches the conversation stored as the call handle', () {
@@ -32,10 +38,7 @@ void main() {
});
test('leaves a call from another chat alone', () {
- const call = CallKitParams(
- id: 'other-id',
- handle: '22222222-2222-4222-8222-222222222222',
- );
+ const call = CallKitParams(id: 'other-id', handle: otherConversation);
expect(
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
@@ -43,4 +46,62 @@ void main() {
);
});
});
+
+ group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
+ final calls = [];
+ late List