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> activeCalls; + + setUp(() { + calls.clear(); + VideoCallRemoteEndRegistry.clear(); + activeCalls = >[ + { + 'id': 'ringing-id', + 'handle': conversation, + 'extra': {'conversationUuid': conversation}, + }, + { + 'id': 'other-id', + 'handle': otherConversation, + 'extra': {'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, ['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); + }, + ); + }); } diff --git a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart index 64850877..09f331da 100644 --- a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart +++ b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart @@ -75,6 +75,15 @@ void main() { expect(shown.single.ios?.audioSessionActive, isFalse); expect(shown.single.ios?.audioSessionMode, 'videoChat'); expect(shown.single.ios?.ringtonePath, isNull); + expect(shown.single.missedCallNotification?.showNotification, isTrue); + expect( + shown.single.missedCallNotification?.subtitle, + VideoCallIncomingPushPresenter.iosMissedCallSubtitle, + ); + expect( + shown.single.missedCallNotification?.callbackText, + VideoCallIncomingPushPresenter.iosMissedCallCallbackText, + ); }); test('iOS stops polling when the push claims the call', () async { diff --git a/test/features/video_calls/services/video_call_remote_end_registry_test.dart b/test/features/video_calls/services/video_call_remote_end_registry_test.dart new file mode 100644 index 00000000..2a24a02c --- /dev/null +++ b/test/features/video_calls/services/video_call_remote_end_registry_test.dart @@ -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, + ); + }); + }); +}