From 8f66998a03e95568558e8f440863d5f48542a136 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 17 Oct 2024 16:44:08 +0200 Subject: [PATCH 01/11] Update build to (54) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2d7269dd7..e5e52d3c2 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 67b5f7f5637f9025a1fccada5f7f207bd197763a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 17 Oct 2024 16:44:17 +0200 Subject: [PATCH 02/11] Remove publisher from corecontext --- Linphone/Core/CoreContext.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 5f168ff1f..44ddbae7e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -363,10 +363,6 @@ final class CoreContext: ObservableObject { mCore.removeDelegate(delegate: delegate) } - func getCorePublisher() -> CoreDelegatePublisher? { - return mCore.publisher - } - } // swiftlint:enable line_length From cc1bcd1666b8cfb5fe0020b13e3aebbdda3e71a2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 21 Oct 2024 10:08:12 +0200 Subject: [PATCH 03/11] Update build version to (55) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e5e52d3c2..e120285cc 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From efa34110c2b7f2daebb83086b61417334296ba4d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 21 Oct 2024 14:08:05 +0200 Subject: [PATCH 04/11] Display chat notification when app is on foreground if the message comes from elsewhere that the currently displayed chatroom --- Linphone/LinphoneApp.swift | 14 ++++++++++++++ Linphone/UI/Main/ContentView.swift | 6 +++--- .../Fragments/ConversationFragment.swift | 12 ++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 9ecfffa09..487346789 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -22,6 +22,7 @@ import linphonesw import UserNotifications let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") +var displayedChatroomPeerAddr: String? class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { @@ -81,6 +82,19 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele completionHandler() } + // Display notifications on foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + Log.info("Received push notification in foreground, payload= \(userInfo)") + + if let callId = userInfo["CallId"] as? String, let peerAddr = userInfo["peer_addr"] as? String, let localAddr = userInfo["local_addr"] as? String { + // Only display notification if we're not in the chatroom they come from + if displayedChatroomPeerAddr != peerAddr { + completionHandler([.banner, .sound]) + } + } + } + func applicationWillTerminate(_ application: UIApplication) { Log.info("IOS applicationWillTerminate") CoreContext.shared.doOnCoreQueue(synchronous: true) { core in diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 776f6f0bd..6a11ce794 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1242,9 +1242,9 @@ struct ContentView: View { } class NavigationManager: ObservableObject { - @Published var selectedCallId: String? = nil - @Published var peerAddr: String? = nil - @Published var localAddr: String? = nil + @Published var selectedCallId: String? + @Published var peerAddr: String? + @Published var localAddr: String? func openChatRoom(callId: String, peerAddr: String, localAddr: String) { self.selectedCallId = callId diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 4a60f942e..a61fd9186 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -69,7 +69,13 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .onAppear() { + displayedChatroomPeerAddr = conversationViewModel.displayedConversation?.remoteSipUri + Log.info("debugtrace = onAppear: displayedChatroomPeerAddr = \(displayedChatroomPeerAddr)") + } .onDisappear { + displayedChatroomPeerAddr = nil + Log.info("debugtrace = onDisappear: displayedChatroomPeerAddr = nil") conversationViewModel.removeConversationDelegate() } .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetails, onDismiss: { @@ -109,7 +115,13 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .onAppear() { + displayedChatroomPeerAddr = conversationViewModel.displayedConversation?.remoteSipUri + Log.info("debugtrace = onAppear: displayedChatroomPeerAddr = \(displayedChatroomPeerAddr)") + } .onDisappear { + displayedChatroomPeerAddr = nil + Log.info("debugtrace = onDisappear: displayedChatroomPeerAddr = nil") conversationViewModel.removeConversationDelegate() } .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetails) { From 532332ad948d97b60708ab05369578bb32dc53b8 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 17 Oct 2024 16:29:00 +0200 Subject: [PATCH 05/11] Fix insertion of multiple messages --- .../ViewModel/ConversationViewModel.swift | 434 +++++++++--------- 1 file changed, 218 insertions(+), 216 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f54dd238d..ba7879790 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -791,257 +791,259 @@ class ConversationViewModel: ObservableObject { } func getNewMessages(eventLogs: [EventLog]) { - eventLogs.enumerated().forEach { index, eventLog in - var attachmentNameList: String = "" - var attachmentList: [Attachment] = [] - var contentText = "" - - if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { - eventLog.chatMessage!.contents.forEach { content in - if content.isText { - contentText = content.utf8Text ?? "" - } else { - if content.filePath == nil || content.filePath!.isEmpty { - // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - if path != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name ?? "???", - url: path!, - type: .fileTransfer, - size: content.fileSize - ) - attachmentNameList += ", \(content.name ?? "???")" - attachmentList.append(attachment) - } - } else if content.name != nil && !content.name!.isEmpty { - if content.type != "video" { + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLogs.last?.chatMessage?.messageId { + eventLogs.enumerated().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" + + if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { + eventLog.chatMessage!.contents.forEach { content in + if content.isText { + contentText = content.utf8Text ?? "" + } else { + if content.filePath == nil || content.filePath!.isEmpty { + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - var typeTmp: AttachmentType = .other - - switch content.type { - case "image": - typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image - case "audio": - typeTmp = content.isVoiceRecording ? .voiceRecording : .audio - case "application": - typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other - case "text": - typeTmp = .text - default: - typeTmp = .other - } if path != nil { let attachment = Attachment( id: UUID().uuidString, - name: content.name!, + name: content.name ?? "???", url: path!, - type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + type: .fileTransfer, size: content.fileSize ) - attachmentNameList += ", \(content.name!)" + attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) } - } else if content.type == "video" { - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) - if path != nil && pathThumbnail != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name!, - thumbnail: pathThumbnail!, - full: path!, - type: .video, - size: content.fileSize - ) - attachmentNameList += ", \(content.name!)" - attachmentList.append(attachment) + } else if content.name != nil && !content.name!.isEmpty { + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + var typeTmp: AttachmentType = .other + + switch content.type { + case "image": + typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + case "audio": + typeTmp = content.isVoiceRecording ? .voiceRecording : .audio + case "application": + typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other + case "text": + typeTmp = .text + default: + typeTmp = .other + } + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else if content.type == "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) + if path != nil && pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + thumbnail: pathThumbnail!, + full: path!, + type: .video, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } } } } } } - } - - let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressPrecCleaned?.clean() - - let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressNextCleaned?.clean() - - let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() - addressCleaned?.clean() - - if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressCleaned!) - } - - let isFirstMessageIncomingTmp = index > 0 - ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() - ) - - let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 - ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) - ) - - let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp - - let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 - - var statusTmp: Message.Status? = .sending - switch eventLog.chatMessage?.state { - case .InProgress: - statusTmp = .sending - case .Delivered: - statusTmp = .sent - case .DeliveredToUser: - statusTmp = .received - case .Displayed: - statusTmp = .read - case .NotDelivered: - statusTmp = .error - default: - statusTmp = .sending - } - - var reactionsTmp: [String] = [] - eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) - }) - - if !attachmentNameList.isEmpty { - attachmentNameList = String(attachmentNameList.dropFirst(2)) - } - - var replyMessageTmp: ReplyMessage? - if eventLog.chatMessage?.replyMessage != nil { - let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressReplyCleaned?.clean() - if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressReplyCleaned!) + let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressPrecCleaned?.clean() + + let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressNextCleaned?.clean() + + let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() + addressCleaned?.clean() + + if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressCleaned!) } - let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + let isFirstMessageIncomingTmp = index > 0 + ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() + ) - var attachmentNameReplyList: String = "" + let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 + ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) + ) - eventLog.chatMessage?.replyMessage?.contents.forEach { content in - if !content.isText { - attachmentNameReplyList += ", \(content.name!)" + let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + + let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 + + var statusTmp: Message.Status? = .sending + switch eventLog.chatMessage?.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + case .NotDelivered: + statusTmp = .error + default: + statusTmp = .sending + } + + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) } - } - - if !attachmentNameReplyList.isEmpty { - attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) - } - - replyMessageTmp = ReplyMessage( - id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, - address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", - isFirstMessage: false, - text: contentReplyText, - isOutgoing: false, - dateReceived: 0, - attachmentsNames: attachmentNameReplyList, - attachments: [] - ) - } - - if eventLog.chatMessage != nil { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, - appData: eventLog.chatMessage!.appdata ?? "", - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachmentsNames: attachmentNameList, - attachments: attachmentList, - replyMessage: replyMessageTmp, - isForward: eventLog.chatMessage?.isForward ?? false, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp, - isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, - ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, - messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + + let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + + var attachmentNameReplyList: String = "" + + eventLog.chatMessage?.replyMessage?.contents.forEach { content in + if !content.isText { + attachmentNameReplyList += ", \(content.name!)" + } + } + + if !attachmentNameReplyList.isEmpty { + attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) + } + + replyMessageTmp = ReplyMessage( + id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, + address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", + isFirstMessage: false, + text: contentReplyText, + isOutgoing: false, + dateReceived: 0, + attachmentsNames: attachmentNameReplyList, + attachments: [] ) - ) + } - if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { - self.addChatMessageDelegate(message: eventLog.chatMessage!) + if eventLog.chatMessage != nil { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, + appData: eventLog.chatMessage!.appdata ?? "", + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, + messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + ) + ) + + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { + self.addChatMessageDelegate(message: eventLog.chatMessage!) + + DispatchQueue.main.async { + Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") + if !self.conversationMessagesSection.isEmpty + && !self.conversationMessagesSection[0].rows.isEmpty + && self.conversationMessagesSection[0].rows[0].message.isOutgoing + && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { + self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false + } + + if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { + self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) + } + + if !message.message.isOutgoing { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + } else { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) + ) DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") - if !self.conversationMessagesSection.isEmpty - && !self.conversationMessagesSection[0].rows.isEmpty - && self.conversationMessagesSection[0].rows[0].message.isOutgoing - && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { - self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false - } - + Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) } else { self.conversationMessagesSection[0].rows.insert(message, at: 0) } - - if !message.message.isOutgoing { - self.displayedConversationUnreadMessagesCount = unreadMessagesCount - } - } - } - } else { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] - ) - ) - - DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") - if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { - self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) - } else { - self.conversationMessagesSection[0].rows.insert(message, at: 0) } } } + + getHistorySize() } - - getHistorySize() } func resetMessage() { From 26e2defbe302beb7225f60a50af790e6b6f5ab6f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 18 Oct 2024 17:32:36 +0200 Subject: [PATCH 06/11] Add call button to the chatroom view --- .../Fragments/ConversationFragment.swift | 1 + .../Model/ConversationModel.swift | 90 ++++++++++++++++++- .../ViewModel/StartCallViewModel.swift | 6 +- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index a61fd9186..65381a267 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -202,6 +202,7 @@ struct ConversationFragment: View { Spacer() Button { + conversationViewModel.displayedConversation!.call() } label: { Image("phone") .renderingMode(.template) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 2992f66d6..0e84f8a20 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -50,6 +50,8 @@ class ConversationModel: ObservableObject { @Published var unreadMessagesCount: Int @Published var avatarModel: ContactAvatarModel + private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? + init(chatRoom: ChatRoom) { self.chatRoom = chatRoom @@ -104,13 +106,97 @@ class ConversationModel: ObservableObject { } func call() { - coreContext.doOnCoreQueue { _ in - if self.chatRoom.peerAddress != nil { + coreContext.doOnCoreQueue { core in + if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && !self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.peerAddress!) + } else if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { + if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { + TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.participants.first!.address!) + } + } else { + //self.createGroupCall(core: core) } } } + func createGroupCall(core: Core) { + let account = core.defaultAccount + if account == nil { + Log.error( + "\(ConversationModel.TAG) No default account found, can't create group call!" + ) + return + } + + do { + let conferenceInfo = try Factory.Instance.createConferenceInfo() + conferenceInfo.organizer = account!.params?.identityAddress + conferenceInfo.subject = self.chatRoom.subject ?? "Conference" + + var participantsList: [ParticipantInfo] = [] + self.chatRoom.participants.forEach { participant in + do { + let info = try Factory.Instance.createParticipantInfo(address: participant.address!) + // For meetings, all participants must have Speaker role + info.role = Participant.Role.Speaker + participantsList.append(info) + } catch let error { + Log.error( + "\(ConversationModel.TAG) Can't create ParticipantInfo: \(error)" + ) + } + } + + conferenceInfo.addParticipantInfos(participantInfos: participantsList) + + Log.info( + "\(ConversationModel.TAG) Creating group call with subject \(self.chatRoom.subject ?? "Conference") and \(participantsList.count) participant(s)" + ) + + let conferenceScheduler = try core.createConferenceScheduler() + self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } catch let error { + Log.error( + "\(ConversationModel.TAG) createGroupCall: \(error)" + ) + } + } + + func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { + self.conferenceSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + Log.info("\(ConversationModel.TAG) Conference scheduler state is \(state)") + if state == ConferenceScheduler.State.Ready { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + + let conferenceAddress = conferenceScheduler.info?.uri + if conferenceAddress != nil { + Log.info( + "\(ConversationModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")" + ) + + TelecomManager.shared.doCallOrJoinConf(address: conferenceAddress!) + } else { + Log.error("\(ConversationModel.TAG) Conference info URI is null!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + } else if state == ConferenceScheduler.State.Error { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + Log.error("\(ConversationModel.TAG) Failed to create group call!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + }) + conferenceScheduler.addDelegate(delegate: self.conferenceSchedulerDelegate!) + } + func getContentTextMessage() { coreContext.doOnCoreQueue { _ in let lastMessage = self.chatRoom.lastMessageInHistory diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 03fcb657e..4270d7fc5 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -94,7 +94,9 @@ class StartCallViewModel: ObservableObject { } } - self.participants.removeAll() + DispatchQueue.main.async { + self.participants.removeAll() + } conferenceInfo.addParticipantInfos(participantInfos: participantsList) @@ -102,7 +104,7 @@ class StartCallViewModel: ObservableObject { "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" ) - let conferenceScheduler = try core.createConferenceScheduler() + let conferenceScheduler = try core.createConferenceScheduler(account: account) self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) conferenceScheduler.account = account // Will trigger the conference creation/update automatically From 3fb50958b350d9c866157e313293f25b92aa10b7 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 10:59:02 +0200 Subject: [PATCH 07/11] Add local network authorization --- Linphone/Info.plist | 2 + .../Fragments/PermissionsFragment.swift | 2 +- Linphone/Utils/PermissionManager.swift | 53 ++++++++++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index fdbc8ca20..89d3fd147 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + App requires access to the local network to establish VoIP connections CFBundleURLTypes diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index 40e2fdc8c..370ae5a38 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -194,7 +194,7 @@ struct PermissionsFragment: View { } .navigationViewStyle(StackNavigationViewStyle()) .navigationBarHidden(true) - .onReceive(permissionManager.$contactsPermissionGranted, perform: { (granted) in + .onReceive(permissionManager.$allPermissionsHaveBeenDisplayed, perform: { (granted) in if granted { withAnimation { sharedMainViewModel.changeWelcomeView() diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index e20bc2d51..dab08971b 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -22,6 +22,7 @@ import Photos import Contacts import UserNotifications import SwiftUI +import Network class PermissionManager: ObservableObject { @@ -32,18 +33,28 @@ class PermissionManager: ObservableObject { @Published var cameraPermissionGranted = false @Published var contactsPermissionGranted = false @Published var microphonePermissionGranted = false + @Published var allPermissionsHaveBeenDisplayed = false private init() {} func getPermissions() { - pushNotificationRequestPermission() - microphoneRequestPermission() - photoLibraryRequestPermission() - cameraRequestPermission() - contactsRequestPermission() + pushNotificationRequestPermission { + let dispatchGroup = DispatchGroup() + + dispatchGroup.enter() + self.microphoneRequestPermission() + self.photoLibraryRequestPermission() + self.cameraRequestPermission() + self.contactsRequestPermission(group: dispatchGroup) + + dispatchGroup.notify(queue: .main) { + // Now request local network authorization last + self.requestLocalNetworkAuthorization() + } + } } - func pushNotificationRequestPermission() { + func pushNotificationRequestPermission(completion: @escaping () -> Void) { let options: UNAuthorizationOptions = [.alert, .sound, .badge] UNUserNotificationCenter.current().requestAuthorization(options: options) { (granted, error) in if let error = error { @@ -52,6 +63,7 @@ class PermissionManager: ObservableObject { DispatchQueue.main.async { self.pushPermissionGranted = granted } + completion() } } @@ -79,12 +91,39 @@ class PermissionManager: ObservableObject { }) } - func contactsRequestPermission() { + func contactsRequestPermission(group: DispatchGroup) { let store = CNContactStore() store.requestAccess(for: .contacts) { success, _ in DispatchQueue.main.async { self.contactsPermissionGranted = success } + group.leave() + } + } + + func requestLocalNetworkAuthorization() { + // Use a general UDP broadcast endpoint to attempt triggering the authorization request + let host = NWEndpoint.Host("255.255.255.255") // Broadcast on the local network + let port = NWEndpoint.Port(12345) // Choose an arbitrary port + + let params = NWParameters.udp + let connection = NWConnection(host: host, port: port, using: params) + + connection.stateUpdateHandler = { newState in + switch newState { + case .ready: + print("Connection ready") + connection.cancel() // Close the connection after establishing it + case .failed(let error): + print("Connection failed: \(error)") + connection.cancel() + default: + break + } + } + connection.start(queue: .main) + DispatchQueue.main.async { + self.allPermissionsHaveBeenDisplayed = true } } } From 6b2a6573be5d19c537800fa487ac855973acd506 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:09:34 +0200 Subject: [PATCH 08/11] Hide keyboard when displaying calls --- Linphone/UI/Call/CallView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d0e46d4b6..4d42ff3b0 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -235,6 +235,7 @@ struct CallView: View { } } .onAppear { + UIApplication.shared.endEditing() fullscreenVideo = false if geo.size.width < 350 || geo.size.height < 350 { buttonSize = 45.0 From 1615f5caa90e927df02f35e6ad4b63b5b1ddc6c5 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:23:31 +0200 Subject: [PATCH 09/11] Fix view layout when app returns to foreground --- Linphone/UI/Main/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 6a11ce794..599143a0d 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1231,6 +1231,7 @@ struct ContentView: View { } .onChange(of: scenePhase) { newPhase in CoreContext.shared.enteredForeground = newPhase == .active + orientation = UIDevice.current.orientation } } From 37b70f4f327f6b44dd30336844ccbe9833ffd940 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 14:43:34 +0200 Subject: [PATCH 10/11] Fix meeting waiting room when headphone is connected --- Linphone/UI/Call/MeetingWaitingRoomFragment.swift | 9 ++++++--- .../ViewModel/MeetingWaitingRoomViewModel.swift | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 8f8920261..e1fae5807 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -52,7 +52,9 @@ struct MeetingWaitingRoomFragment: View { }) .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { @@ -72,8 +74,9 @@ struct MeetingWaitingRoomFragment: View { } .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 24d4b1480..365f78d2d 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -50,6 +50,11 @@ class MeetingWaitingRoomViewModel: ObservableObject { func resetMeetingRoomView() { if self.telecomManager.meetingWaitingRoomSelected != nil { + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + } catch _ { + + } coreContext.doOnCoreQueue { core in let conf = core.findConferenceInformationFromUri(uri: self.telecomManager.meetingWaitingRoomSelected!) @@ -73,10 +78,12 @@ class MeetingWaitingRoomViewModel: ObservableObject { if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { userNameTmp = friend!.address!.displayName! } else { - if core.defaultAccount!.contactAddress!.displayName != nil { - userNameTmp = core.defaultAccount!.contactAddress!.displayName! - } else if core.defaultAccount!.contactAddress!.username != nil { - userNameTmp = core.defaultAccount!.contactAddress!.username! + if core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil { + if core.defaultAccount!.contactAddress!.displayName != nil { + userNameTmp = core.defaultAccount!.contactAddress!.displayName! + } else if core.defaultAccount!.contactAddress!.username != nil { + userNameTmp = core.defaultAccount!.contactAddress!.username! + } } } From d7d1b195c665415fd717f9054318ddcbedddbb0f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 15:43:48 +0200 Subject: [PATCH 11/11] Fix fullscreen video mode in oneone call --- Linphone/UI/Call/CallView.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4d42ff3b0..7ef1d8a93 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -517,14 +517,8 @@ struct CallView: View { } } .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom ) .scaledToFill() .clipped() @@ -711,6 +705,7 @@ struct CallView: View { ) .background(Color.gray900) .cornerRadius(20) + .padding(.top, callViewModel.isOneOneCall && fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.safeAreaInsets.bottom + 10 : 0) .padding(.horizontal, fullscreenVideo && !telecomManager.isPausedByRemote ? 0 : 4) .onRotate { newOrientation in let oldOrientation = orientation