Add recording list

This commit is contained in:
Benoit Martins 2025-11-13 16:49:33 +01:00
parent bcee4439f5
commit b462657a77
12 changed files with 513 additions and 23 deletions

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" fill="#4e6074" viewBox="0 0 256 256"><path d="M222.37,158.46l-47.11-21.11-.13-.06a16,16,0,0,0-15.17,1.4,8.12,8.12,0,0,0-.75.56L134.87,160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16,16,0,0,0,1.32-15.06l0-.12L97.54,33.64a16,16,0,0,0-16.62-9.52A56.26,56.26,0,0,0,32,80c0,79.4,64.6,144,144,144a56.26,56.26,0,0,0,55.88-48.92A16,16,0,0,0,222.37,158.46ZM176,208A128.14,128.14,0,0,1,48,80,40.2,40.2,0,0,1,82.87,40a.61.61,0,0,0,0,.12l21,47L83.2,111.86a6.13,6.13,0,0,0-.57.77,16,16,0,0,0-1,15.7c9.06,18.53,27.73,37.06,46.46,46.11a16,16,0,0,0,15.75-1.14,8.44,8.44,0,0,0,.74-.56L168.89,152l47,21.05h0s.08,0,.11,0A40.21,40.21,0,0,1,176,208Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M222.37,158.46l-47.11-21.11-.13-.06a16,16,0,0,0-15.17,1.4,8.12,8.12,0,0,0-.75.56L134.87,160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16,16,0,0,0,1.32-15.06l0-.12L97.54,33.64a16,16,0,0,0-16.62-9.52A56.26,56.26,0,0,0,32,80c0,79.4,64.6,144,144,144a56.26,56.26,0,0,0,55.88-48.92A16,16,0,0,0,222.37,158.46ZM176,208A128.14,128.14,0,0,1,48,80,40.2,40.2,0,0,1,82.87,40a.61.61,0,0,0,0,.12l21,47L83.2,111.86a6.13,6.13,0,0,0-.57.77,16,16,0,0,0-1,15.7c9.06,18.53,27.73,37.06,46.46,46.11a16,16,0,0,0,15.75-1.14,8.44,8.44,0,0,0,.74-.56L168.89,152l47,21.05h0s.08,0,.11,0A40.21,40.21,0,0,1,176,208Z"></path></svg>

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 733 B

View file

@ -200,6 +200,26 @@ class CoreContext: ObservableObject {
self.forceRemotePushToMatchVoipPushSettings(account: acc)
}
let container = FileUtil.sharedContainerUrl()
let recordingsDir = container.appendingPathComponent("Library/Recordings")
let fm = FileManager.default
if !fm.fileExists(atPath: recordingsDir.path) {
do {
try fm.createDirectory(
at: recordingsDir,
withIntermediateDirectories: true,
attributes: nil
)
print("Recordings directory created.")
} catch {
print("Error creating directory: \(error)")
}
} else {
print("Recordings directory already exists.")
}
self.mCoreDelegate = CoreDelegateStub(onGlobalStateChanged: { (core: Core, state: GlobalState, _: String) in
if state == GlobalState.On {
#if DEBUG

View file

@ -195,17 +195,13 @@ class TelecomManager: ObservableObject {
}
}
private func makeRecordFilePath() -> String {
var filePath = "recording_"
let now = Date()
let dateFormat = DateFormatter()
dateFormat.dateFormat = "E-d-MMM-yyyy-HH-mm-ss"
let date = dateFormat.string(from: now)
filePath = filePath.appending("\(date).mkv")
private func makeRecordFilePath(address: String) -> String {
var filePath = "call_recording_sip_" + address.dropFirst(4) + "_on_"
let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
let writablePath = paths[0]
return writablePath.appending("/\(filePath)")
filePath = filePath.appending("\(Int(Date().timeIntervalSince1970)).mkv")
let writablePath = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Recordings/\(filePath)")
return writablePath.path
}
func doCall(core: Core, addr: Address, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws {
@ -237,7 +233,7 @@ class TelecomManager: ObservableObject {
// Log.directLog(BCTBX_LOG_DEBUG, text: "record file path: \(writablePath)")
// lcallParams.recordFile = writablePath
lcallParams.recordFile = makeRecordFilePath()
lcallParams.recordFile = makeRecordFilePath(address: addr.asStringUriOnly())
if isSas {
lcallParams.mediaEncryption = .ZRTP
@ -292,7 +288,7 @@ class TelecomManager: ObservableObject {
func acceptCall(core: Core, call: Call, hasVideo: Bool) {
do {
let callParams = try core.createCallParams(call: call)
callParams.recordFile = makeRecordFilePath()
callParams.recordFile = makeRecordFilePath(address: call.remoteAddress?.asStringUriOnly() ?? "")
callParams.videoEnabled = hasVideo
/*if (ConfigManager.instance().lpConfigBoolForKey(key: "edge_opt_preference")) {
let low_bandwidth = (AppManager.network() == .network_2g)

View file

@ -65,6 +65,7 @@ struct ContentView: View {
@State var isShowConversationFragment = false
@State var isShowAccountProfileFragment = false
@State var isShowSettingsFragment = false
@State var isShowRecordingsListFragment = false
@State var isShowHelpFragment = false
@State var fullscreenVideo = false
@ -1041,6 +1042,7 @@ struct ContentView: View {
isShowLoginFragment: $isShowLoginFragment,
isShowAccountProfileFragment: $isShowAccountProfileFragment,
isShowSettingsFragment: $isShowSettingsFragment,
isShowRecordingsListFragment: $isShowRecordingsListFragment,
isShowHelpFragment: $isShowHelpFragment
)
.environmentObject(accountProfileViewModel)
@ -1285,6 +1287,14 @@ struct ContentView: View {
.transition(.move(edge: .trailing))
}
if isShowRecordingsListFragment {
RecordingsListFragment(
isShowRecordingsListFragment: $isShowRecordingsListFragment
)
.zIndex(3)
.transition(.move(edge: .trailing))
}
if isShowHelpFragment {
HelpFragment(
isShowHelpFragment: $isShowHelpFragment

View file

@ -32,6 +32,7 @@ struct SideMenu: View {
@Binding var isShowLoginFragment: Bool
@Binding var isShowAccountProfileFragment: Bool
@Binding var isShowSettingsFragment: Bool
@Binding var isShowRecordingsListFragment: Bool
@Binding var isShowHelpFragment: Bool
@State private var showHelp = false
@ -137,12 +138,15 @@ struct SideMenu: View {
}
}
/*
SideMenuEntry(
iconName: "record-fill",
title: "recordings_title"
)
*/
).onTapGesture {
self.menuClose()
withAnimation {
isShowRecordingsListFragment = true
}
}
SideMenuEntry(
iconName: "question",
@ -152,7 +156,6 @@ struct SideMenu: View {
withAnimation {
isShowHelpFragment = true
}
}
}
.padding(.bottom, safeAreaInsets.bottom + 13)
@ -176,15 +179,15 @@ struct SideMenu: View {
#Preview {
GeometryReader { geometry in
@State var triggerNavigateToLogin: Bool = false
SideMenu(
width: geometry.size.width / 5 * 4,
isOpen: .constant(true),
menuClose: {},
safeAreaInsets: geometry.safeAreaInsets,
isShowLoginFragment: $triggerNavigateToLogin,
isShowLoginFragment: .constant(false),
isShowAccountProfileFragment: .constant(false),
isShowSettingsFragment: .constant(false),
isShowRecordingsListFragment: .constant(false),
isShowHelpFragment: .constant(false)
)
.ignoresSafeArea(.all)

View file

@ -25,10 +25,6 @@ struct HelpFragment: View {
@Binding var isShowHelpFragment: Bool
@State var advancedSettingsIsOpen: Bool = false
@FocusState var isVoicemailUriFocused: Bool
var showAssistant: Bool {
(CoreContext.shared.coreIsStarted && CoreContext.shared.accounts.isEmpty)
|| SharedMainViewModel.shared.displayProfileMode

View file

@ -152,8 +152,10 @@ struct HistoryRow: View {
if !historyModel.isConf {
Image("phone")
.renderingMode(.template)
.resizable()
.frame(width: 25, height: 25)
.foregroundStyle(Color.grayMain2c600)
.padding(.all, 10)
.padding(.trailing, 5)
.highPriorityGesture(

View file

@ -0,0 +1,142 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-iphone
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import SwiftUI
struct RecordingsListFragment: View {
@StateObject private var recordingsListViewModel = RecordingsListViewModel()
@Binding var isShowRecordingsListFragment: Bool
var body: some View {
NavigationView {
ZStack {
VStack(spacing: 1) {
Rectangle()
.foregroundColor(Color.orangeMain500)
.edgesIgnoringSafeArea(.top)
.frame(height: 0)
HStack {
Image("caret-left")
.renderingMode(.template)
.resizable()
.foregroundStyle(Color.orangeMain500)
.frame(width: 25, height: 25, alignment: .leading)
.padding(.all, 10)
.padding(.top, 4)
.padding(.leading, -10)
.onTapGesture {
withAnimation {
isShowRecordingsListFragment = false
}
}
Text("recordings_title")
.default_text_style_orange_800(styleSize: 16)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
.lineLimit(1)
Spacer()
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.padding(.horizontal)
.padding(.bottom, 4)
.background(.white)
ScrollView {
VStack(spacing: 0) {
VStack(spacing: 20) {
ForEach(Array(recordingsListViewModel.recordings.enumerated()), id: \.offset) { index, recording in
if index == 0 || recording.month != recordingsListViewModel.recordings[index-1].month {
createMonthLine(model: recording)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
VStack {
HStack {
Image("phone")
.renderingMode(.template)
.resizable()
.frame(width: 25, height: 25)
.foregroundStyle(Color.grayMain2c600)
Text(recording.displayName)
.default_text_style_700(styleSize: 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
Text(recording.dateTime)
.default_text_style(styleSize: 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
VStack {
Image("play-fill")
.renderingMode(.template)
.resizable()
.foregroundStyle(Color.orangeMain500)
.frame(width: 30, height: 30)
.padding(.leading, -6)
Spacer()
Text(recording.formattedDuration)
.default_text_style(styleSize: 14)
.frame(alignment: .center)
}
.padding(.trailing, 6)
}
.frame(height: 60)
.padding(20)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .gray.opacity(0.4), radius: 4)
}
}
.padding(.all, 20)
}
.frame(maxWidth: .infinity)
}
.background(Color.gray100)
}
.background(Color.gray100)
}
.navigationTitle("")
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationTitle("")
.navigationBarHidden(true)
}
@ViewBuilder
func createMonthLine(model: RecordingModel) -> some View {
Text(model.month)
.fontWeight(.bold)
.padding(5)
.default_text_style_500(styleSize: 22)
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-iphone
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import linphonesw
import SwiftUI
class RecordingModel: ObservableObject {
static let TAG = "[Recording Model]"
var filePath: String = ""
var fileName: String = ""
var sipUri: String = ""
var displayName: String = ""
var timestamp: Int64 = 0
var month: String = ""
var dateTime: String = ""
var formattedDuration: String = ""
var duration: Int = 0
init(filePath: String, fileName: String, isLegacy: Bool = false) {
self.filePath = filePath
self.fileName = fileName
var sipUriTmp: String = ""
var displayNameTmp: String = ""
var timestampTmp: Int64 = 0
CoreContext.shared.doOnCoreQueue { core in
if isLegacy {
let parts = fileName.split(separator: "_")
let username = String(parts.first ?? "")
let sipAddress = core.interpretUrl(url: username, applyInternationalPrefix: false)
sipUriTmp = sipAddress?.asStringUriOnly() ?? username
if let address = sipAddress {
ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { friendResult in
if let addressFriend = friendResult {
displayNameTmp = addressFriend.name!
} else {
if address.displayName != nil {
displayNameTmp = address.displayName!
} else if address.username != nil {
displayNameTmp = address.username!
} else {
displayNameTmp = String(address.asStringUriOnly().dropFirst(4))
}
}
}
} else {
displayNameTmp = sipUriTmp
}
if parts.count > 1 {
let parsedDate = String(parts[1])
let formatter = DateFormatter()
formatter.dateFormat = "dd-MM-yyyy-HH-mm-ss"
if let date = formatter.date(from: parsedDate) {
timestampTmp = Int64(date.timeIntervalSince1970 * 1000)
} else {
Log.error("\(RecordingModel.TAG) Failed to parse legacy timestamp \(parsedDate)")
}
}
} else {
let headerLength = LinphoneUtils.RECORDING_FILE_NAME_HEADER.count
let withoutHeader = String(fileName.dropFirst(headerLength))
guard let sepRange = withoutHeader.range(of: LinphoneUtils.RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR) else {
fatalError("\(RecordingModel.TAG) Invalid file name format \(withoutHeader)")
}
sipUriTmp = String(withoutHeader[..<sepRange.lowerBound])
let sipAddress = try? Factory.Instance.createAddress(addr: "sip:" + sipUriTmp)
if let address = sipAddress {
ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { friendResult in
if let addressFriend = friendResult {
displayNameTmp = addressFriend.name!
} else {
if address.displayName != nil {
displayNameTmp = address.displayName!
} else if address.username != nil {
displayNameTmp = address.username!
} else {
displayNameTmp = String(address.asStringUriOnly().dropFirst(4))
}
}
}
} else {
displayNameTmp = sipUriTmp
}
let start = sepRange.upperBound
if let dotRange = withoutHeader.range(of: ".", options: .backwards) {
let parsedTimestamp = String(withoutHeader[start..<dotRange.lowerBound])
Log.info("\(RecordingModel.TAG) Extract SIP URI \(sipUriTmp) and timestamp \(parsedTimestamp) from file \(fileName)")
timestampTmp = Int64(parsedTimestamp) ?? 0
}
}
self.sipUri = sipUriTmp
self.displayName = displayNameTmp
self.timestamp = timestampTmp
self.month = self.formattedMonthYear(timestamp: timestampTmp)
self.dateTime = self.formattedDateTime(timestamp: timestampTmp)
do {
let audioPlayer = try core.createLocalPlayer(soundCardName: nil, videoDisplayName: nil, windowId: nil)
try? audioPlayer.open(filename: filePath)
self.duration = audioPlayer.duration
print("durationduration \(audioPlayer.duration) \((audioPlayer.duration/1000).convertDurationToString())")
self.formattedDuration = (audioPlayer.duration/1000).convertDurationToString()
} catch {
self.duration = 0
self.formattedDuration = "??:??"
}
}
}
func delete() async {
Log.info("\(RecordingModel.TAG) Deleting call recording \(filePath)")
//await FileUtils.deleteFile(path: filePath)
}
func formattedDateTime(timestamp: Int64) -> String {
let locale = Locale.current
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
let dateFormatter = DateFormatter()
dateFormatter.locale = locale
if Calendar.current.isDate(date, equalTo: .now, toGranularity: .year) {
dateFormatter.dateFormat = locale.identifier == "fr_FR"
? "EEEE d MMMM"
: "EEEE, MMMM d"
} else {
dateFormatter.dateFormat = locale.identifier == "fr_FR"
? "EEEE d MMMM yyyy"
: "EEEE, MMMM d, yyyy"
}
let timeFormatter = DateFormatter()
timeFormatter.locale = locale
timeFormatter.dateFormat = locale.identifier == "fr_FR"
? "HH:mm"
: "h:mm a"
return "\(dateFormatter.string(from: date).capitalized) - \(timeFormatter.string(from: date))"
}
func formattedMonthYear(timestamp: Int64) -> String {
let locale = Locale.current
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
let formatter = DateFormatter()
formatter.locale = locale
if Calendar.current.isDate(date, equalTo: .now, toGranularity: .year) {
formatter.dateFormat = "MMMM"
} else {
formatter.dateFormat = "MMMM yyyy"
}
return formatter.string(from: date).capitalized
}
}

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-iphone
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Foundation
import Combine
class RecordingsListViewModel: ObservableObject {
private let TAG = "[RecordingsListViewModel]"
@Published var recordings: [RecordingModel] = []
@Published var searchBarVisible: Bool = false
@Published var searchFilter: String = ""
@Published var fetchInProgress: Bool = true
@Published var focusSearchBarEvent: Bool? = nil
private let legacyRecordRegex = try! NSRegularExpression(pattern: ".*/(.*)_(\\d{2}-\\d{2}-\\d{4}-\\d{2}-\\d{2}-\\d{2})\\..*")
init() {
fetchInProgress = true
CoreContext.shared.doOnCoreQueue { core in
self.computeList(filter: "")
}
}
func openSearchBar() {
searchBarVisible = true
focusSearchBarEvent = true
}
func closeSearchBar() {
clearFilter()
searchBarVisible = false
focusSearchBarEvent = false
}
func clearFilter() {
if searchFilter.isEmpty {
searchBarVisible = false
focusSearchBarEvent = false
} else {
searchFilter = ""
}
}
func applyFilter(_ filter: String) {
DispatchQueue.global(qos: .background).async {
self.computeList(filter: filter)
}
}
private func computeList(filter: String) {
var list: [RecordingModel] = []
let dir1 = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Recordings")
if let files = try? FileManager.default.contentsOfDirectory(at: dir1, includingPropertiesForKeys: nil) {
for file in files {
let path = file.path
let name = file.lastPathComponent
let model = RecordingModel(filePath: path, fileName: name)
if filter.isEmpty || model.sipUri.contains(filter) {
list.append(model)
}
}
}
list.sort { $0.timestamp > $1.timestamp }
DispatchQueue.main.async {
self.recordings = list
self.fetchInProgress = false
}
}
}

View file

@ -21,6 +21,11 @@ import Foundation
import linphonesw
class LinphoneUtils: NSObject {
static let RECORDING_FILE_NAME_HEADER = "call_recording_sip_"
static let RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR = "_on_"
static let RECORDING_MKV_FILE_EXTENSION = ".mkv"
static let RECORDING_SMFF_FILE_EXTENSION = ".smff"
public class func isChatRoomAGroup(chatRoom: ChatRoom) -> Bool {
let oneToOne = chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue)
let conference = chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue)

View file

@ -147,6 +147,9 @@
D78E062C2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E062B2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift */; };
D78E062E2BEA69F400CE3783 /* AudioRouteBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E062D2BEA69F400CE3783 /* AudioRouteBottomSheet.swift */; };
D78E06302BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */; };
D795F57E2EC5F9500022C17D /* RecordingsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D795F57D2EC5F9480022C17D /* RecordingsListFragment.swift */; };
D795F5802EC5F9660022C17D /* RecordingsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D795F57F2EC5F95B0022C17D /* RecordingsListViewModel.swift */; };
D795F5832EC6133C0022C17D /* RecordingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D795F5822EC6133A0022C17D /* RecordingModel.swift */; };
D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79622332B1DFE600037EACD /* DialerBottomSheet.swift */; };
D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; };
D79F1C162CD3D6AD00FF0A05 /* ConversationInfoFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79F1C152CD3D6AD00FF0A05 /* ConversationInfoFragment.swift */; };
@ -379,6 +382,9 @@
D78E062B2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallStatisticsSheetBottomSheet.swift; sourceTree = "<group>"; };
D78E062D2BEA69F400CE3783 /* AudioRouteBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteBottomSheet.swift; sourceTree = "<group>"; };
D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLayoutBottomSheet.swift; sourceTree = "<group>"; };
D795F57D2EC5F9480022C17D /* RecordingsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsListFragment.swift; sourceTree = "<group>"; };
D795F57F2EC5F95B0022C17D /* RecordingsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsListViewModel.swift; sourceTree = "<group>"; };
D795F5822EC6133A0022C17D /* RecordingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingModel.swift; sourceTree = "<group>"; };
D79622332B1DFE600037EACD /* DialerBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialerBottomSheet.swift; sourceTree = "<group>"; };
D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = "<group>"; };
D79F1C152CD3D6AD00FF0A05 /* ConversationInfoFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInfoFragment.swift; sourceTree = "<group>"; };
@ -695,6 +701,7 @@
D7A03FBE2ACC2E010081A588 /* History */,
66E56BC52BA45E49006CE56F /* Meetings */,
66D382032CEB7DB80063E1C5 /* Models */,
D795F57A2EC5F89B0022C17D /* Recordings */,
D7DC096A2CFA192200A6D47C /* Settings */,
D7A2EDD42AC180FE005D90FC /* Viewmodel */,
D719ABB82ABC67BF00B41C10 /* ContentView.swift */,
@ -895,6 +902,40 @@
path = ViewModel;
sourceTree = "<group>";
};
D795F57A2EC5F89B0022C17D /* Recordings */ = {
isa = PBXGroup;
children = (
D795F57B2EC5F8FF0022C17D /* Fragments */,
D795F5812EC613220022C17D /* Models */,
D795F57C2EC5F9090022C17D /* ViewModel */,
);
path = Recordings;
sourceTree = "<group>";
};
D795F57B2EC5F8FF0022C17D /* Fragments */ = {
isa = PBXGroup;
children = (
D795F57D2EC5F9480022C17D /* RecordingsListFragment.swift */,
);
path = Fragments;
sourceTree = "<group>";
};
D795F57C2EC5F9090022C17D /* ViewModel */ = {
isa = PBXGroup;
children = (
D795F57F2EC5F95B0022C17D /* RecordingsListViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
};
D795F5812EC613220022C17D /* Models */ = {
isa = PBXGroup;
children = (
D795F5822EC6133A0022C17D /* RecordingModel.swift */,
);
path = Models;
sourceTree = "<group>";
};
D7A03FBB2ACC2D850081A588 /* Contacts */ = {
isa = PBXGroup;
children = (
@ -1280,6 +1321,7 @@
buildActionMask = 2147483647;
files = (
D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */,
D795F57E2EC5F9500022C17D /* RecordingsListFragment.swift in Sources */,
D7ADF6002AFE356400212231 /* Avatar.swift in Sources */,
D7CEE03B2B7A234200FD79B7 /* ConversationsFragment.swift in Sources */,
D71707202AC5989C0037746F /* TextExtension.swift in Sources */,
@ -1380,8 +1422,10 @@
D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */,
D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */,
6613A0B42BAEBE3F008923A4 /* MeetingViewModel.swift in Sources */,
D795F5832EC6133C0022C17D /* RecordingModel.swift in Sources */,
D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */,
66C492012B24DB6900CEA16D /* Log.swift in Sources */,
D795F5802EC5F9660022C17D /* RecordingsListViewModel.swift in Sources */,
D756C8182D352C5F00A58F2F /* CorePreferences.swift in Sources */,
C6A5A9432C10B5ED0070FEA4 /* DecodableExtension.swift in Sources */,
D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */,