forked from eatvio/eatvio_app
feat(video_calls): continous ringing on a defferent device fix
This commit is contained in:
@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
||||
Log.i(TAG, "onMessageReceived: ${message.data}")
|
||||
|
||||
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
|
||||
if (message == null) return
|
||||
|
||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,16 @@ class MainActivity: FlutterActivity() {
|
||||
when (call.method) {
|
||||
"dismissIncomingCalls" -> {
|
||||
try {
|
||||
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
||||
val args = call.arguments as? Map<*, *>
|
||||
val callId = args?.get("call_id")?.toString()
|
||||
?: args?.get("callId")?.toString()
|
||||
val conversationUuid = args?.get("conversation_uuid")?.toString()
|
||||
?: args?.get("conversationUuid")?.toString()
|
||||
VideoCallPushBridge.dismissIncomingCalls(
|
||||
applicationContext,
|
||||
callId = callId,
|
||||
conversationUuid = conversationUuid,
|
||||
)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.hiennv.flutter_callkit_incoming.addCall
|
||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||
import com.hiennv.flutter_callkit_incoming.removeCall
|
||||
import java.util.HashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -25,11 +26,13 @@ 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 TOMBSTONE_TTL_MS = 2 * 60 * 1000L
|
||||
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val playbackLock = Any()
|
||||
private val playbackGeneration = AtomicInteger(0)
|
||||
private val tombstonedCallIds = HashMap<String, Long>()
|
||||
|
||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||
@@ -118,6 +121,12 @@ object VideoCallPushBridge {
|
||||
storedAppContext = appContext
|
||||
ensureIncomingCallCallbackRegistered()
|
||||
|
||||
val serverCallId = callId(data)
|
||||
if (isTombstoned(serverCallId)) {
|
||||
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
|
||||
return
|
||||
}
|
||||
|
||||
val callData = buildCallDataBundle(data)
|
||||
if (callData == null) {
|
||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||
@@ -126,7 +135,7 @@ object VideoCallPushBridge {
|
||||
|
||||
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||
val seenGeneration = playbackGeneration.get()
|
||||
Log.i(TAG, "showIncomingCall: conversation=$callId")
|
||||
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
|
||||
|
||||
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||
// pushes on a background thread, while Answer runs on the main thread.
|
||||
@@ -180,17 +189,43 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
fun dismissIncomingCalls(
|
||||
context: Context,
|
||||
callId: String? = null,
|
||||
conversationUuid: String? = null,
|
||||
) {
|
||||
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (normalizedCallId != null) {
|
||||
markTombstone(normalizedCallId)
|
||||
}
|
||||
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
Log.i(
|
||||
TAG,
|
||||
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||
"conversation=${normalizedConversation ?: "-"}",
|
||||
)
|
||||
playbackGeneration.incrementAndGet()
|
||||
|
||||
val dismiss = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
val allBundles = calls.map { it.toBundle() }
|
||||
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
|
||||
val bundles = if (targeted) {
|
||||
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
|
||||
} else {
|
||||
allBundles
|
||||
}
|
||||
|
||||
val activeMatches = activeCallBundle?.let { bundle ->
|
||||
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
|
||||
} == true
|
||||
if (activeMatches || !targeted) {
|
||||
stopPlaybackLocked(appContext)
|
||||
}
|
||||
|
||||
for (bundle in bundles) {
|
||||
try {
|
||||
@@ -198,25 +233,39 @@ object VideoCallPushBridge {
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!targeted || bundles.isNotEmpty()) {
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!targeted) {
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
|
||||
}
|
||||
} else {
|
||||
for (bundle in bundles) {
|
||||
try {
|
||||
removeCall(appContext, Data.fromBundle(bundle))
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeMatches || !targeted) {
|
||||
stopIncomingRingtone()
|
||||
}
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
}
|
||||
}
|
||||
@@ -228,6 +277,18 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
dismissIncomingCalls(context)
|
||||
}
|
||||
|
||||
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
|
||||
dismissIncomingCalls(
|
||||
context = context,
|
||||
callId = callId(data),
|
||||
conversationUuid = conversationUuid(data),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureIncomingCallCallbackRegistered() {
|
||||
if (callbackRegistered) return
|
||||
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||
@@ -403,6 +464,8 @@ object VideoCallPushBridge {
|
||||
|
||||
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
||||
val conversationUuid = conversationUuid(data) ?: return null
|
||||
val serverCallId = callId(data)
|
||||
val callKitId = serverCallId ?: conversationUuid
|
||||
val callerName = data["counterpartName"]
|
||||
?: data["counterpart_name"]
|
||||
?: data["caller_name"]
|
||||
@@ -428,6 +491,9 @@ object VideoCallPushBridge {
|
||||
|
||||
val extra = HashMap<String, Any?>()
|
||||
extra["conversationUuid"] = conversationUuid
|
||||
if (!serverCallId.isNullOrBlank()) {
|
||||
extra["call_id"] = serverCallId
|
||||
}
|
||||
extra["counterpartName"] = callerName
|
||||
if (avatarUrl.isNotBlank()) {
|
||||
extra["counterpartAvatarUrl"] = avatarUrl
|
||||
@@ -438,9 +504,13 @@ object VideoCallPushBridge {
|
||||
if (counterpartIsConsultant) {
|
||||
extra["counterpartIsConsultant"] = true
|
||||
}
|
||||
val messageId = data["message_id"] ?: data["messageId"]
|
||||
if (!messageId.isNullOrBlank()) {
|
||||
extra["message_id"] = messageId
|
||||
}
|
||||
|
||||
return Bundle().apply {
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||
@@ -465,6 +535,74 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private fun callId(data: Map<String, String>): String? {
|
||||
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
|
||||
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun bundleMatches(
|
||||
bundle: Bundle,
|
||||
callId: String?,
|
||||
conversationUuid: String?,
|
||||
): Boolean {
|
||||
val normalizedCallId = callId?.trim()?.lowercase()
|
||||
if (!normalizedCallId.isNullOrEmpty()) {
|
||||
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
|
||||
if (id != null && id == normalizedCallId) return true
|
||||
val extraCallId = extraString(bundle, "call_id")
|
||||
?: extraString(bundle, "callId")
|
||||
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
|
||||
if (handle != null && handle == normalizedConversation) return true
|
||||
val extraConversation = extraString(bundle, "conversationUuid")
|
||||
?: extraString(bundle, "conversation_uuid")
|
||||
return extraConversation?.lowercase() == normalizedConversation
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun extraString(bundle: Bundle, key: String): String? {
|
||||
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
|
||||
?: return null
|
||||
val value = extra[key] ?: return null
|
||||
val text = value.toString().trim()
|
||||
return text.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun markTombstone(callId: String) {
|
||||
val normalized = callId.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
pruneTombstones()
|
||||
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
|
||||
}
|
||||
|
||||
private fun isTombstoned(callId: String?): Boolean {
|
||||
val normalized = callId?.trim()?.lowercase() ?: return false
|
||||
if (normalized.isEmpty()) return false
|
||||
pruneTombstones()
|
||||
return tombstonedCallIds.containsKey(normalized)
|
||||
}
|
||||
|
||||
private fun pruneTombstones() {
|
||||
val now = System.currentTimeMillis()
|
||||
val iterator = tombstonedCallIds.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (entry.value <= now) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun conversationUuid(data: Map<String, String>): String? {
|
||||
val fromData = data["conversationUuid"]
|
||||
?: data["conversation_uuid"]
|
||||
|
||||
@@ -62,12 +62,16 @@ class PushNavigationHandler {
|
||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||
if (endedPayload != null) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source '
|
||||
'call_id=${endedPayload.callId ?? '-'} '
|
||||
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
callId: endedPayload.callId,
|
||||
reason: endedPayload.reason,
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -232,7 +232,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||
conversationUuid: message.roomId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ class VideoCallManager {
|
||||
VideoCallForegroundCoordinator? foregroundCoordinator,
|
||||
VideoCallLifecycleBinding? lifecycleBinding,
|
||||
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
|
||||
Future<String?> Function()? resolveHwId,
|
||||
}) : _videoCallRepository = videoCallRepository,
|
||||
_profileRepository = profileRepository,
|
||||
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
|
||||
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
|
||||
_audioSession =
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
|
||||
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
|
||||
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
|
||||
if (Platform.isIOS) {
|
||||
// LiveKit rewrites the audio session whenever a remote audio track
|
||||
@@ -52,6 +54,7 @@ class VideoCallManager {
|
||||
final VideoCallForegroundCoordinator _foreground;
|
||||
final VideoCallLifecycleBinding _lifecycleBinding;
|
||||
final VideoCallAudioSessionCoordinator _audioSession;
|
||||
final Future<String?> Function() _resolveHwId;
|
||||
|
||||
static const lk.AudioCaptureOptions _micCaptureOptions =
|
||||
lk.AudioCaptureOptions(
|
||||
@@ -78,6 +81,8 @@ class VideoCallManager {
|
||||
bool _callEndedNotified = true;
|
||||
bool _forceEndRequested = false;
|
||||
bool _sessionMayRequestToken = false;
|
||||
String? _activeCallId;
|
||||
final Set<String> _callDeclineNotifiedKeys = <String>{};
|
||||
final Map<String, DateTime> _leftConversationAt = {};
|
||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||
bool _callStageEntered = false;
|
||||
@@ -102,6 +107,14 @@ class VideoCallManager {
|
||||
|
||||
bool get callEndedNotified => _callEndedNotified;
|
||||
|
||||
String? get activeCallId => _activeCallId;
|
||||
|
||||
void rememberCallId(String? callId) {
|
||||
final id = callId?.trim();
|
||||
if (id == null || id.isEmpty) return;
|
||||
_activeCallId = id;
|
||||
}
|
||||
|
||||
bool get isActiveCall =>
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
_callStageEntered &&
|
||||
@@ -441,7 +454,10 @@ class VideoCallManager {
|
||||
await _disconnectLiveKitOnly();
|
||||
if (shouldNotifyBackend) {
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
await _videoCallRepository.notifyCallEnded(
|
||||
uuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _foreground.stopOngoingCall();
|
||||
@@ -451,17 +467,22 @@ class VideoCallManager {
|
||||
_videoPausedForBackground = false;
|
||||
_liveKitDisposed = true;
|
||||
_myProfileNameFuture = null;
|
||||
_activeCallId = null;
|
||||
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||
}
|
||||
|
||||
/// Notifies backend that this device accepted an incoming call so ringing
|
||||
/// stops on the user's other devices.
|
||||
Future<void> notifyIncomingCallAccepted(String conversationUuid) async {
|
||||
Future<void> notifyIncomingCallAccepted(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid.trim();
|
||||
if (uuid.isEmpty) return;
|
||||
rememberCallId(callId);
|
||||
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
|
||||
|
||||
final hwId = (await DeviceHwIdProvider.current())?.trim();
|
||||
final hwId = (await _resolveHwId())?.trim();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
|
||||
_callAcceptNotifiedConversationUuids.add(uuid);
|
||||
@@ -469,6 +490,7 @@ class VideoCallManager {
|
||||
await _videoCallRepository.notifyCallAccepted(
|
||||
conversationUuid: uuid,
|
||||
hwId: hwId,
|
||||
callId: _activeCallId ?? callId,
|
||||
);
|
||||
} catch (_) {
|
||||
_callAcceptNotifiedConversationUuids.remove(uuid);
|
||||
@@ -476,28 +498,67 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
/// Decline from incoming UI before joining the LiveKit room.
|
||||
Future<void> declineIncomingCall(String conversationUuid) async {
|
||||
///
|
||||
/// Uses `POST /decline` so the backend can stop ringing on the user's
|
||||
/// other devices. Do not call `/ended` for a ringing decline.
|
||||
Future<void> declineIncomingCall(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid.trim();
|
||||
final resolvedCallId = callId?.trim().isNotEmpty == true
|
||||
? callId!.trim()
|
||||
: _activeCallId;
|
||||
if (uuid.isEmpty) {
|
||||
await endCall();
|
||||
await _cleanupLocalCallAfterDecline();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_snapshot.conversationUuid == uuid) {
|
||||
await endCall();
|
||||
return;
|
||||
}
|
||||
|
||||
await endCall();
|
||||
if (!incomingDeclineShouldNotifyBackend(
|
||||
final declineKey = '${uuid.toLowerCase()}|${resolvedCallId ?? ''}';
|
||||
final shouldNotify = incomingDeclineShouldNotifyBackend(
|
||||
leftAt: _leftConversationAt[uuid],
|
||||
now: DateTime.now(),
|
||||
)) {
|
||||
);
|
||||
|
||||
await _cleanupLocalCallAfterDecline(conversationUuid: uuid);
|
||||
|
||||
if (!shouldNotify || _callDeclineNotifiedKeys.contains(declineKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final hwId = (await _resolveHwId())?.trim();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
|
||||
_callDeclineNotifiedKeys.add(declineKey);
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
} catch (_) {}
|
||||
await _videoCallRepository.notifyCallDeclined(
|
||||
conversationUuid: uuid,
|
||||
hwId: hwId,
|
||||
callId: resolvedCallId,
|
||||
);
|
||||
} catch (_) {
|
||||
_callDeclineNotifiedKeys.remove(declineKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
if (uuid != null && uuid.isNotEmpty) {
|
||||
_leftConversationAt[uuid] = DateTime.now();
|
||||
}
|
||||
_callEndedNotified = true;
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
await _foreground.stopOngoingCall();
|
||||
await _stopCallAudioSession();
|
||||
_lifecycleBinding.detach();
|
||||
_callStageEntered = false;
|
||||
_videoPausedForBackground = false;
|
||||
_liveKitDisposed = true;
|
||||
_myProfileNameFuture = null;
|
||||
_activeCallId = null;
|
||||
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||
}
|
||||
|
||||
/// [currentUiEnabled] reflects what the user sees on the mic control
|
||||
@@ -805,6 +866,7 @@ class VideoCallManager {
|
||||
_roomConnectFuture = null;
|
||||
_clearPreparedConnectionCredentials();
|
||||
_callAcceptNotifiedConversationUuids.clear();
|
||||
_callDeclineNotifiedKeys.clear();
|
||||
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
|
||||
}
|
||||
|
||||
@@ -1011,9 +1073,11 @@ class VideoCallManager {
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
rememberCallId(tokenResponse.callId);
|
||||
_preparedWsUrl = tokenResponse.wsUrl;
|
||||
_preparedToken = tokenResponse.token;
|
||||
|
||||
@@ -1058,8 +1122,10 @@ class VideoCallManager {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
if (_liveKitDisposed) return;
|
||||
rememberCallId(tokenResponse.callId);
|
||||
wsUrl = tokenResponse.wsUrl;
|
||||
token = tokenResponse.token;
|
||||
_preparedWsUrl = wsUrl;
|
||||
|
||||
@@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
|
||||
required String token,
|
||||
required String roomName,
|
||||
required String wsUrl,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
@JsonKey(name: 'call_status') String? callStatus,
|
||||
@JsonKey(name: 'call_expires_at') String? callExpiresAt,
|
||||
}) = _VideoCallTokenResponse;
|
||||
|
||||
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
|
||||
@@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
||||
const factory VideoCallEndedResponse({required bool sent}) =
|
||||
_VideoCallEndedResponse;
|
||||
const factory VideoCallEndedResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
}) = _VideoCallEndedResponse;
|
||||
|
||||
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallEndedResponseFromJson(json);
|
||||
@@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
|
||||
const factory VideoCallAcceptResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
@JsonKey(name: 'already_accepted') bool? alreadyAccepted,
|
||||
}) = _VideoCallAcceptResponse;
|
||||
|
||||
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallAcceptResponseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse {
|
||||
const factory VideoCallDeclineResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
}) = _VideoCallDeclineResponse;
|
||||
|
||||
factory VideoCallDeclineResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallDeclineResponseFromJson(json);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ class VideoCallRepository {
|
||||
|
||||
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
|
||||
|
||||
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
|
||||
Future<VideoCallTokenResponse> getParticipantToken(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: callId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,20 +22,46 @@ class VideoCallRepository {
|
||||
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
|
||||
}
|
||||
|
||||
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
|
||||
Future<VideoCallEndedResponse> notifyCallEnded(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) {
|
||||
final body = <String, dynamic>{
|
||||
if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(),
|
||||
};
|
||||
return videoCallService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: _emptyJsonBody,
|
||||
body: body.isEmpty ? _emptyJsonBody : body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
||||
required String conversationUuid,
|
||||
required String hwId,
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.notifyCallAccepted(
|
||||
conversationUuid: conversationUuid,
|
||||
body: <String, dynamic>{'hw_id': hwId},
|
||||
body: <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
if (callId != null && callId.trim().isNotEmpty)
|
||||
'call_id': callId.trim(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||
required String conversationUuid,
|
||||
required String hwId,
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
if (callId != null && callId.trim().isNotEmpty)
|
||||
'call_id': callId.trim(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,7 +701,18 @@ class VideoCallCallKitCoordinator {
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
|
||||
final params = _paramsForCallId(callKitId);
|
||||
final callId = _parseCallId(params?.extra ?? const <String, Object?>{}) ??
|
||||
(callKitId.contains('-') ? callKitId : null);
|
||||
if (callId != null) {
|
||||
_manager.rememberCallId(callId);
|
||||
}
|
||||
unawaited(
|
||||
_manager.notifyIncomingCallAccepted(
|
||||
conversationUuid,
|
||||
callId: callId,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await _waitForAcceptNavigationReady();
|
||||
@@ -874,6 +885,8 @@ class VideoCallCallKitCoordinator {
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final callId = _parseCallId(extra) ??
|
||||
(callKitId.contains('-') ? callKitId : null);
|
||||
final remoteEndDismissal =
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
@@ -911,7 +924,10 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
await _manager.declineIncomingCall(conversationUuid);
|
||||
await _manager.declineIncomingCall(
|
||||
conversationUuid,
|
||||
callId: callId,
|
||||
);
|
||||
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||
await _cubit.onCallEndedExternally();
|
||||
}
|
||||
@@ -977,6 +993,10 @@ class VideoCallCallKitCoordinator {
|
||||
return '';
|
||||
}
|
||||
|
||||
static String? _parseCallId(Map<String, Object?> extra) {
|
||||
return _parseString(extra, const ['call_id', 'callId']);
|
||||
}
|
||||
|
||||
static String? _parseString(Map<String, Object?> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
|
||||
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
/// Cause of a `video_call_ended` push / Echo event.
|
||||
///
|
||||
/// This is not the session status. It tells the client how to close ringing
|
||||
/// or an active call.
|
||||
enum VideoCallEndReason {
|
||||
accepted,
|
||||
declined,
|
||||
cancelled,
|
||||
ended,
|
||||
timeout;
|
||||
|
||||
static VideoCallEndReason? tryParse(Object? raw) {
|
||||
if (raw == null) return null;
|
||||
final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_');
|
||||
if (normalized.isEmpty) return null;
|
||||
for (final value in VideoCallEndReason.values) {
|
||||
if (value.name == normalized) return value;
|
||||
}
|
||||
if (normalized == 'hangup' || normalized == 'hang_up') {
|
||||
return VideoCallEndReason.ended;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close ringing UI; do not tear down an already-accepted LiveKit call.
|
||||
bool get dismissesRingingOnly =>
|
||||
this == VideoCallEndReason.accepted ||
|
||||
this == VideoCallEndReason.declined ||
|
||||
this == VideoCallEndReason.cancelled ||
|
||||
this == VideoCallEndReason.timeout;
|
||||
|
||||
/// Tear down an accepted in-progress call.
|
||||
bool get endsActiveCall =>
|
||||
this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:developer';
|
||||
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_end_reason.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_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';
|
||||
@@ -13,30 +16,99 @@ abstract final class VideoCallEndedPushHandler {
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
|
||||
await endActiveCallIfMatches(conversationUuid);
|
||||
static Future<void> dismissIncomingCallUi({
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
VideoCallEndReason? reason,
|
||||
}) async {
|
||||
final normalizedCallId = callId?.trim();
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||
VideoCallTombstoneRegistry.mark(normalizedCallId);
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallEndedPushHandler: dismiss '
|
||||
'call_id=${normalizedCallId ?? '-'} '
|
||||
'conversation=${conversationUuid ?? '-'} '
|
||||
'reason=${reason?.name ?? '-'}',
|
||||
name: 'VideoCallEndedPush',
|
||||
);
|
||||
|
||||
final shouldEndActive = reason == null || reason.endsActiveCall;
|
||||
if (shouldEndActive && reason != VideoCallEndReason.accepted) {
|
||||
await endActiveCallIfMatches(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: normalizedCallId,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
|
||||
await _androidPushChannel.invokeMethod<void>(
|
||||
'dismissIncomingCalls',
|
||||
<String, String?>{
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty)
|
||||
'call_id': normalizedCallId,
|
||||
if (conversationUuid != null && conversationUuid.trim().isNotEmpty)
|
||||
'conversation_uuid': conversationUuid.trim(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _dismissViaCallKit(conversationUuid);
|
||||
await _dismissViaCallKit(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: normalizedCallId,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
|
||||
static Future<void> endActiveCallIfMatches({
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
if (uuid == null || uuid.isEmpty) return;
|
||||
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
|
||||
|
||||
final cubit = sl<VideoCallSessionCubit>();
|
||||
if (cubit.state.conversationUuid != uuid) return;
|
||||
// callId is reserved for ringing dismiss matching; active sessions are
|
||||
// still keyed by conversation until the cubit stores call_id.
|
||||
try {
|
||||
await cubit.handleRemoteCallEnded();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static bool callMatches({
|
||||
required CallKitParams call,
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
}) {
|
||||
final normalizedCallId = callId?.trim().toLowerCase();
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||
final id = call.id.trim().toLowerCase();
|
||||
if (id.isNotEmpty && id == normalizedCallId) return true;
|
||||
final extra = call.extra;
|
||||
if (extra != null) {
|
||||
for (final key in const ['call_id', 'callId']) {
|
||||
final value = extra[key];
|
||||
if (value is String &&
|
||||
value.trim().toLowerCase() == normalizedCallId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// When call_id is known, never fall back to conversation matching:
|
||||
// a stale ended event must not close a newer ring in the same chat.
|
||||
return false;
|
||||
}
|
||||
|
||||
final uuid = conversationUuid?.trim().toLowerCase();
|
||||
if (uuid == null || uuid.isEmpty) return false;
|
||||
return callMatchesConversation(call, uuid);
|
||||
}
|
||||
|
||||
static bool callMatchesConversation(
|
||||
CallKitParams call,
|
||||
String conversationUuid,
|
||||
@@ -57,11 +129,18 @@ abstract final class VideoCallEndedPushHandler {
|
||||
/// 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,
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
final normalizedCallId = callId?.trim();
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
final hasTarget =
|
||||
(normalizedCallId != null && normalizedCallId.isNotEmpty) ||
|
||||
(uuid != null && uuid.isNotEmpty);
|
||||
if (!hasTarget) {
|
||||
for (final call in activeCalls) {
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
}
|
||||
@@ -69,7 +148,13 @@ abstract final class VideoCallEndedPushHandler {
|
||||
return;
|
||||
}
|
||||
for (final call in activeCalls) {
|
||||
if (!callMatchesConversation(call, uuid)) continue;
|
||||
if (!callMatches(
|
||||
call: call,
|
||||
conversationUuid: uuid,
|
||||
callId: normalizedCallId,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(call.id);
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
/// Marker for a data-only `video_call_ended` push.
|
||||
class VideoCallEndedPushPayload {
|
||||
const VideoCallEndedPushPayload({this.conversationUuid});
|
||||
const VideoCallEndedPushPayload({
|
||||
this.conversationUuid,
|
||||
this.callId,
|
||||
this.reason,
|
||||
this.acceptedDeviceId,
|
||||
this.acceptedClient,
|
||||
});
|
||||
|
||||
final String? conversationUuid;
|
||||
final String? callId;
|
||||
final VideoCallEndReason? reason;
|
||||
final String? acceptedDeviceId;
|
||||
final String? acceptedClient;
|
||||
|
||||
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
||||
final rawUuid =
|
||||
data['conversationUuid'] ??
|
||||
data['conversation_uuid'] ??
|
||||
data['uuid'];
|
||||
final conversationUuid = rawUuid?.toString().trim();
|
||||
|
||||
final conversationUuid = _string(data, const [
|
||||
'conversationUuid',
|
||||
'conversation_uuid',
|
||||
'uuid',
|
||||
]);
|
||||
final callId = _string(data, const ['call_id', 'callId']);
|
||||
final acceptedDeviceId = _string(data, const [
|
||||
'accepted_device_id',
|
||||
'acceptedDeviceId',
|
||||
]);
|
||||
final acceptedClient = _string(data, const [
|
||||
'accepted_client',
|
||||
'acceptedClient',
|
||||
]);
|
||||
|
||||
return VideoCallEndedPushPayload(
|
||||
conversationUuid:
|
||||
conversationUuid == null || conversationUuid.isEmpty
|
||||
? null
|
||||
: conversationUuid,
|
||||
conversationUuid: conversationUuid,
|
||||
callId: callId,
|
||||
reason: VideoCallEndReason.tryParse(
|
||||
data['reason'] ?? data['end_reason'] ?? data['endReason'],
|
||||
),
|
||||
acceptedDeviceId: acceptedDeviceId,
|
||||
acceptedClient: acceptedClient,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _string(Map<String, dynamic> data, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = data[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,16 @@ abstract final class VideoCallFcmHandler {
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
|
||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source '
|
||||
'call_id=${endedPayload.callId ?? '-'} '
|
||||
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
try {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
callId: endedPayload.callId,
|
||||
reason: endedPayload.reason,
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -53,17 +55,23 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
final ios = isIos ?? Platform.isIOS;
|
||||
if (VideoCallTombstoneRegistry.contains(payload.callId)) {
|
||||
log(
|
||||
'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}',
|
||||
name: 'VideoCallIncoming',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||
return;
|
||||
}
|
||||
final callId = ios
|
||||
? VideoCallKitId.iosUuid(
|
||||
final callId = VideoCallKitId.resolve(
|
||||
callId: payload.callId,
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
) ??
|
||||
payload.conversationUuid
|
||||
: payload.conversationUuid;
|
||||
isIos: ios,
|
||||
);
|
||||
|
||||
if (ios) {
|
||||
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
|
||||
@@ -91,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
duration: 45000,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': payload.conversationUuid,
|
||||
if (payload.callId != null) 'call_id': payload.callId,
|
||||
if (payload.counterpartName != null)
|
||||
'counterpartName': payload.counterpartName,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
@@ -99,6 +108,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
'localParticipantName': payload.localParticipantName,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
if (payload.messageId != null) 'message_id': payload.messageId,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
missedCallNotification: const NotificationParams(
|
||||
@@ -138,6 +148,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
await _androidPushChannel
|
||||
.invokeMethod<void>('showIncomingCall', <String, String>{
|
||||
'conversation_uuid': payload.conversationUuid,
|
||||
if (payload.callId != null) 'call_id': payload.callId!,
|
||||
if (payload.counterpartName != null)
|
||||
'caller_name': payload.counterpartName!,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
@@ -150,6 +161,8 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
: 'false',
|
||||
if (payload.messageId != null)
|
||||
'message_id': payload.messageId.toString(),
|
||||
if (payload.expiresAt != null)
|
||||
'expires_at': payload.expiresAt!.toUtc().toIso8601String(),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
||||
/// Resolves the CallKit / ConnectionService id for an incoming call.
|
||||
///
|
||||
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
||||
/// id is derived from that uuid plus the chat message id, so one ring is one
|
||||
/// call and the next ring in the same chat still appears. Android notifications
|
||||
/// keep the conversation uuid: the same id updates one incoming notification.
|
||||
/// Prefer the backend `call_id` (stable for the whole session). Legacy
|
||||
/// fallback: conversation uuid + message id on iOS, conversation uuid on
|
||||
/// Android.
|
||||
abstract final class VideoCallKitId {
|
||||
static final RegExp _uuidPrefix = RegExp(
|
||||
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||
);
|
||||
|
||||
static String resolve({
|
||||
String? callId,
|
||||
required String conversationUuid,
|
||||
int? messageId,
|
||||
required bool isIos,
|
||||
}) {
|
||||
final serverCallId = callId?.trim();
|
||||
if (serverCallId != null && serverCallId.isNotEmpty) {
|
||||
return serverCallId;
|
||||
}
|
||||
if (isIos) {
|
||||
return iosUuid(
|
||||
conversationUuid: conversationUuid,
|
||||
messageId: messageId,
|
||||
) ??
|
||||
conversationUuid;
|
||||
}
|
||||
return conversationUuid;
|
||||
}
|
||||
|
||||
static String? iosUuid({
|
||||
required String conversationUuid,
|
||||
required int? messageId,
|
||||
|
||||
@@ -6,6 +6,8 @@ class VideoCallPushPayload {
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
this.messageId,
|
||||
this.callId,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
@@ -14,6 +16,8 @@ class VideoCallPushPayload {
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
final int? messageId;
|
||||
final String? callId;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
@@ -52,6 +56,8 @@ class VideoCallPushPayload {
|
||||
'isConsultant',
|
||||
]),
|
||||
messageId: _messageId(data),
|
||||
callId: _string(data, const ['call_id', 'callId']),
|
||||
expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +125,9 @@ class VideoCallPushPayload {
|
||||
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
||||
_conversationUuid(data);
|
||||
|
||||
static String? callIdFromData(Map<String, dynamic> data) =>
|
||||
_string(data, const ['call_id', 'callId']);
|
||||
|
||||
static String? _conversationUuid(Map<String, dynamic> data) {
|
||||
final fromData = _string(data, const [
|
||||
'conversationUuid',
|
||||
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
|
||||
return null;
|
||||
}
|
||||
|
||||
static DateTime? _dateTime(Map<String, dynamic> data, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = data[key];
|
||||
if (value is DateTime) return value;
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return DateTime.tryParse(value.trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
|
||||
@@ -11,6 +11,7 @@ abstract class VideoCallService {
|
||||
@GET('/api/v2/chat/{conversation}/video-call/token')
|
||||
Future<VideoCallTokenResponse> getParticipantToken({
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Query('call_id') String? callId,
|
||||
});
|
||||
|
||||
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
|
||||
@@ -29,4 +30,10 @@ abstract class VideoCallService {
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
|
||||
@POST('/api/v2/chat/{conversation}/video-call/decline')
|
||||
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Remembers cancelled / declined call ids so a late `video_call_started`
|
||||
/// push does not show an already-finished ring.
|
||||
abstract final class VideoCallTombstoneRegistry {
|
||||
static const Duration defaultTtl = Duration(minutes: 2);
|
||||
|
||||
static final Map<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
|
||||
|
||||
static void mark(
|
||||
String? callId, {
|
||||
DateTime? now,
|
||||
Duration ttl = defaultTtl,
|
||||
}) {
|
||||
final id = _normalize(callId);
|
||||
if (id == null) return;
|
||||
final at = now ?? DateTime.now();
|
||||
_expiresAtByCallId[id] = at.add(ttl);
|
||||
}
|
||||
|
||||
static bool contains(String? callId, {DateTime? now}) {
|
||||
final id = _normalize(callId);
|
||||
if (id == null) return false;
|
||||
_prune(now ?? DateTime.now());
|
||||
return _expiresAtByCallId.containsKey(id);
|
||||
}
|
||||
|
||||
static void clear() => _expiresAtByCallId.clear();
|
||||
|
||||
static void _prune(DateTime now) {
|
||||
_expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now));
|
||||
}
|
||||
|
||||
static String? _normalize(String? callId) {
|
||||
final id = callId?.trim().toLowerCase();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ void main() {
|
||||
videoCallRepository: mockRepository,
|
||||
foregroundCoordinator: foreground,
|
||||
lifecycleBinding: lifecycleBinding,
|
||||
resolveHwId: () async => 'test-hw-id-android-01',
|
||||
);
|
||||
mockRoom = MockRoom();
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
@@ -94,6 +95,13 @@ void main() {
|
||||
await manager.declineIncomingCall('test-uuid');
|
||||
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
verifyNever(
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
hwId: anyNamed('hwId'),
|
||||
callId: anyNamed('callId'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,15 +120,34 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'decline of a call that was never joined notifies the backend',
|
||||
'decline of a call that was never joined notifies the backend via /decline',
|
||||
() async {
|
||||
when(
|
||||
mockRepository.notifyCallEnded(any),
|
||||
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
hwId: anyNamed('hwId'),
|
||||
callId: anyNamed('callId'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => const VideoCallDeclineResponse(
|
||||
sent: true,
|
||||
devicesNotified: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await manager.declineIncomingCall('other-uuid');
|
||||
await manager.declineIncomingCall(
|
||||
'other-uuid',
|
||||
callId: 'call-uuid-1',
|
||||
);
|
||||
|
||||
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
|
||||
verify(
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: 'other-uuid',
|
||||
hwId: 'test-hw-id-android-01',
|
||||
callId: 'call-uuid-1',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ void main() {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.getParticipantToken(conversationUuid);
|
||||
|
||||
@@ -33,20 +35,28 @@ void main() {
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('No token'));
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenThrow(Exception('No token'));
|
||||
|
||||
expect(
|
||||
() => repository.getParticipantToken(conversationUuid),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
|
||||
@@ -91,6 +101,60 @@ void main() {
|
||||
expect(captured.single as Map<String, dynamic>, isEmpty);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - передаёт call_id когда он известен', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||
final expected = VideoCallEndedResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
await repository.notifyCallEnded(conversationUuid, callId: 'call-1');
|
||||
|
||||
final captured = verify(
|
||||
mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: captureAnyNamed('body'),
|
||||
),
|
||||
).captured.single as Map<String, dynamic>;
|
||||
expect(captured, <String, dynamic>{'call_id': 'call-1'});
|
||||
});
|
||||
|
||||
test('notifyCallDeclined - передаёт hw_id и call_id', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap(
|
||||
'video_calls/video_call_decline_response.json',
|
||||
);
|
||||
final expected = VideoCallDeclineResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
hwId: 'device-android-001',
|
||||
callId: 'call-1',
|
||||
);
|
||||
|
||||
expect(result.sent, isTrue);
|
||||
expect(result.devicesNotified, 1);
|
||||
|
||||
final captured = verify(
|
||||
mockService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: captureAnyNamed('body'),
|
||||
),
|
||||
).captured.single as Map<String, dynamic>;
|
||||
expect(captured, <String, dynamic>{
|
||||
'hw_id': 'device-android-001',
|
||||
'call_id': 'call-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
|
||||
@@ -47,6 +47,36 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.callMatches', () {
|
||||
test('matches by call_id and ignores conversation fallback', () {
|
||||
const call = CallKitParams(
|
||||
id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
handle: conversation,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatches(
|
||||
call: call,
|
||||
conversationUuid: conversation,
|
||||
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatches(
|
||||
call: call,
|
||||
conversationUuid: conversation,
|
||||
callId: 'stale-call-id',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||
final calls = <MethodCall>[];
|
||||
late List<Map<String, Object?>> activeCalls;
|
||||
@@ -93,6 +123,40 @@ void main() {
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
|
||||
});
|
||||
|
||||
test('ends only the matching call_id', () async {
|
||||
activeCalls = <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'id': 'call-a',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': 'call-a',
|
||||
},
|
||||
},
|
||||
<String, Object?>{
|
||||
'id': 'call-b',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': 'call-b',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: conversation,
|
||||
callId: 'call-a',
|
||||
);
|
||||
|
||||
final ended = calls
|
||||
.where((call) => call.method == 'endCall')
|
||||
.map((call) => (call.arguments as Map)['id'])
|
||||
.toList();
|
||||
expect(ended, <String>['call-a']);
|
||||
expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'without a conversation ends every call and marks all of them',
|
||||
() async {
|
||||
|
||||
@@ -60,6 +60,24 @@ void main() {
|
||||
expect(payload!.conversationUuid, 'abc-123');
|
||||
});
|
||||
|
||||
test('parses ended video call push with call_id and reason', () {
|
||||
final payload = VideoCallEndedPushPayload.tryParse({
|
||||
'type': 'video_call_ended',
|
||||
'conversation_uuid': 'abc-123',
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
'reason': 'declined',
|
||||
'accepted_device_id': 'device-a',
|
||||
'accepted_client': 'android',
|
||||
});
|
||||
|
||||
expect(payload, isNotNull);
|
||||
expect(payload!.conversationUuid, 'abc-123');
|
||||
expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||
expect(payload.reason?.name, 'declined');
|
||||
expect(payload.acceptedDeviceId, 'device-a');
|
||||
expect(payload.acceptedClient, 'android');
|
||||
});
|
||||
|
||||
test('parses message id for a stable CallKit uuid', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'type': 'video_call_started',
|
||||
@@ -77,6 +95,28 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers call_id as CallKit id when present', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'type': 'video_call_started',
|
||||
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
|
||||
'message_id': '42',
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
'expires_at': '2026-04-27T14:31:00+00:00',
|
||||
});
|
||||
|
||||
expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||
expect(payload?.expiresAt, isNotNull);
|
||||
expect(
|
||||
VideoCallKitId.resolve(
|
||||
callId: payload!.callId,
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
isIos: true,
|
||||
),
|
||||
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores regular chat push', () {
|
||||
expect(
|
||||
VideoCallPushPayload.tryParse({
|
||||
|
||||
@@ -21,23 +21,32 @@ void main() {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
|
||||
final result = await mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
);
|
||||
|
||||
expect(result, isA<VideoCallTokenResponse>());
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку', () async {
|
||||
const conversationUuid = 'conv-uuid-error';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('Network error'));
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenThrow(Exception('Network error'));
|
||||
|
||||
expect(
|
||||
() => mockService.getParticipantToken(conversationUuid: conversationUuid),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(VideoCallTombstoneRegistry.clear);
|
||||
|
||||
test('marks a call id so a late incoming can be suppressed', () {
|
||||
const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b';
|
||||
final now = DateTime.utc(2026, 10, 21, 10);
|
||||
|
||||
VideoCallTombstoneRegistry.mark(callId, now: now);
|
||||
|
||||
expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue);
|
||||
expect(
|
||||
VideoCallTombstoneRegistry.contains(
|
||||
callId,
|
||||
now: now.add(const Duration(minutes: 3)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
6
test/fixtures/video_calls/video_call_decline_response.json
vendored
Normal file
6
test/fixtures/video_calls/video_call_decline_response.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sent": true,
|
||||
"devices_notified": 1,
|
||||
"call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b",
|
||||
"status": "declined"
|
||||
}
|
||||
Reference in New Issue
Block a user